| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | |
|
| 8 | |
|
| 9 | | use super::*; |
| 10 | | use crate::cert::{certify_challenge, SignedCertificate}; |
| 11 | | use crate::credentials::{hub_of, Credentials}; |
| 12 | | use crate::protocol::{http_get, http_json, RemoteOptions}; |
| 13 | |
|
| 14 | |
|
| 15 | |
|
| 16 | | const MARGIN_MS: u64 = 2 * 60 * 1000; |
| 17 | |
|
| 18 | | impl Repo { |
| 19 | | |
| 20 | | |
| 21 | 1× | fn keyless_hub(&self) -> Result<String> { |
| 22 | 1× | let url = match &self.config.keyless_hub { |
| 23 | 0× | Some(h) => h.clone(), |
| 24 | 1× | None => self |
| 25 | 1× | .config |
| 26 | 1× | .remotes |
| 27 | 1× | .get(&self.default_remote()) |
| 28 | 1× | .cloned() |
| 29 | 1× | .ok_or_else(|| { |
| 30 | 1× | RepoError::Msg( |
| 31 | 1× | "author.signer is keyless, but neither keyless.hub nor a default remote names the hub that certifies keys" |
| 32 | 1× | .into(), |
| 33 | 1× | ) |
| 34 | 1× | })?, |
| 35 | | }; |
| 36 | 0× | hub_of(&url).ok_or_else(|| RepoError::Msg(format!("{url} is not a hub URL"))) |
| 37 | 1× | } |
| 38 | |
|
| 39 | | |
| 40 | | |
| 41 | 1× | pub(crate) fn keyless_secret(&self) -> Result<(KeyId, SecretKey)> { |
| 42 | 1× | let now = Hlc::now().wall_ms; |
| 43 | 1× | if let Some((k, sk, until)) = self.keyless_key.borrow().as_ref() { |
| 44 | 0× | if now + MARGIN_MS < *until { |
| 45 | 0× | return Ok((*k, sk.clone())); |
| 46 | 0× | } |
| 47 | 1× | } |
| 48 | 1× | let hub = self.keyless_hub()?; |
| 49 | 0× | let home = self |
| 50 | 0× | .user_config |
| 51 | 0× | .parent() |
| 52 | 0× | .map(Path::to_path_buf) |
| 53 | 0× | .unwrap_or_else(default_home); |
| 54 | 0× | let creds = Credentials::at(home.clone()); |
| 55 | 0× | let token = creds.grant(&hub).map_err(RepoError::Msg)?.ok_or_else(|| { |
| 56 | 0× | RepoError::Msg(format!( |
| 57 | 0× | "author.signer is keyless, but there is no agent grant for {hub}: its owner makes one with `oh agent grant NAME`, and `oxy auth grant TOKEN` keeps it" |
| 58 | 0× | )) |
| 59 | 0× | })?; |
| 60 | 0× | let options = RemoteOptions { |
| 61 | 0× | token: Some(token.clone()), |
| 62 | 0× | ca: creds.get(&hub).map_err(RepoError::Msg)?.and_then(|e| e.ca), |
| 63 | 0× | ..RemoteOptions::default() |
| 64 | | }; |
| 65 | 0× | let sk = SecretKey::generate(); |
| 66 | 0× | let id = sk.identity(&self.config.author_name, &self.config.author_email, now); |
| 67 | 0× | let key = id.hash(); |
| 68 | 0× | let proof = sk |
| 69 | 0× | .sign_challenge(&key, &certify_challenge(&token)) |
| 70 | 0× | .map_err(|e| RepoError::Msg(e.to_string()))?; |
| 71 | 0× | let body = serde_json::json!({ |
| 72 | 0× | "identity": crate::dsse::base64(&id.encode()), |
| 73 | 0× | "proof": crate::dsse::base64(&crate::cbor::encode(&proof.to_value())), |
| 74 | 0× | "scope": "changes,refs,seal", |
| 75 | | }); |
| 76 | 0× | let (code, answer) = http_json( |
| 77 | 0× | "POST", |
| 78 | 0× | &format!("{hub}/api/v1/keyless/certify"), |
| 79 | 0× | Some(&body.to_string()), |
| 80 | 0× | &options, |
| 81 | 0× | )?; |
| 82 | 0× | let reply: serde_json::Value = serde_json::from_str(&answer).unwrap_or_default(); |
| 83 | 0× | if !(200..300).contains(&code) { |
| 84 | 0× | let why = reply["error"].as_str().unwrap_or(answer.trim()); |
| 85 | 0× | return fail(format!( |
| 86 | | "{hub} would not certify a key (HTTP {code}): {why}" |
| 87 | | )); |
| 88 | 0× | } |
| 89 | 0× | let cert = reply["certificate"] |
| 90 | 0× | .as_str() |
| 91 | 0× | .and_then(crate::dsse::base64_decode) |
| 92 | 0× | .ok_or_else(|| RepoError::Msg(format!("{hub} answered with no certificate")))?; |
| 93 | 0× | let cert = SignedCertificate::decode(&cert) |
| 94 | 0× | .map_err(|e| RepoError::Msg(format!("{hub}'s certificate: {e}")))?; |
| 95 | 0× | if cert.cert.key_id() != key { |
| 96 | 0× | return fail(format!( |
| 97 | | "{hub} certified another key than the one it was asked for" |
| 98 | | )); |
| 99 | 0× | } |
| 100 | | |
| 101 | | |
| 102 | 0× | let issuer = match self.store.get_identity(&cert.cert.issuer) { |
| 103 | 0× | Ok(i) => i, |
| 104 | | Err(_) => { |
| 105 | 0× | let bytes = http_get( |
| 106 | 0× | &format!("{hub}/.well-known/oxyhub-key"), |
| 107 | 0× | &RemoteOptions { |
| 108 | 0× | token: None, |
| 109 | 0× | ..options.clone() |
| 110 | 0× | }, |
| 111 | 0× | )?; |
| 112 | 0× | let i = Identity::decode(&bytes)?; |
| 113 | 0× | if i.hash() != cert.cert.issuer { |
| 114 | 0× | return fail(format!( |
| 115 | | "{hub}'s published key is not the one that signed its certificate" |
| 116 | | )); |
| 117 | 0× | } |
| 118 | 0× | i |
| 119 | | } |
| 120 | | }; |
| 121 | 0× | cert.verify(&issuer) |
| 122 | 0× | .map_err(|e| RepoError::Msg(format!("{hub}'s certificate: {e}")))?; |
| 123 | | |
| 124 | | |
| 125 | | |
| 126 | | |
| 127 | 0× | if let Some(g) = &cert.cert.grant { |
| 128 | 0× | let grantor = g.grant.grantor; |
| 129 | 0× | if self.store.get_identity(&grantor).is_err() { |
| 130 | 0× | let sent = reply["grantor_identity"] |
| 131 | 0× | .as_str() |
| 132 | 0× | .and_then(crate::dsse::base64_decode) |
| 133 | 0× | .and_then(|b| Identity::decode(&b).ok()); |
| 134 | 0× | let kept = || { |
| 135 | 0× | std::fs::read( |
| 136 | 0× | home.join("keys") |
| 137 | 0× | .join(format!("{}.identity", grantor.to_hex())), |
| 138 | | ) |
| 139 | 0× | .ok() |
| 140 | 0× | .and_then(|b| Identity::decode(&b).ok()) |
| 141 | 0× | }; |
| 142 | 0× | match sent.filter(|i| i.hash() == grantor).or_else(|| kept().filter(|i| i.hash() == grantor)) { |
| 143 | 0× | Some(i) => { |
| 144 | 0× | self.store.put_identity(&i)?; |
| 145 | | } |
| 146 | | None => { |
| 147 | 0× | return fail(format!( |
| 148 | | "the grant is signed by {}, whose identity neither {hub} nor this home has: export it there (oxy identity export) into this home's keys/", |
| 149 | 0× | grantor.short(12) |
| 150 | | )) |
| 151 | | } |
| 152 | | } |
| 153 | 0× | } |
| 154 | 0× | } |
| 155 | 0× | if let (Some(owner), Some(g)) = (&self.config.author_for, &cert.cert.grant) { |
| 156 | 0× | self.follow_author(owner, &cert.cert.subject, g.grant.grantor, &reply, &home)?; |
| 157 | 0× | } |
| 158 | 0× | self.store.put_identity(&issuer)?; |
| 159 | 0× | self.store.put_identity(&id)?; |
| 160 | 0× | self.store.put_certificate(&cert)?; |
| 161 | 0× | *self.keyless_key.borrow_mut() = Some((key, sk.clone(), cert.cert.expires)); |
| 162 | 0× | Ok((key, sk)) |
| 163 | 1× | } |
| 164 | | } |
| 165 | |
|
| 166 | | impl Repo { |
| 167 | | |
| 168 | | |
| 169 | | |
| 170 | | |
| 171 | | |
| 172 | 0× | fn follow_author( |
| 173 | 0× | &self, |
| 174 | 0× | owner: &str, |
| 175 | 0× | subject: &str, |
| 176 | 0× | grantor: KeyId, |
| 177 | 0× | reply: &serde_json::Value, |
| 178 | 0× | home: &Path, |
| 179 | 0× | ) -> Result<()> { |
| 180 | 0× | let granted_by = subject.rsplit_once('@').map(|(_, o)| o).unwrap_or(subject); |
| 181 | 0× | if !granted_by.eq_ignore_ascii_case(owner) { |
| 182 | 0× | return fail(format!( |
| 183 | | "author.for is {owner}, but this home's grant is from {granted_by}: set author.for to the account that granted it" |
| 184 | | )); |
| 185 | 0× | } |
| 186 | 0× | let chain: Vec<Identity> = reply["author_chain"] |
| 187 | 0× | .as_array() |
| 188 | 0× | .map(|a| { |
| 189 | 0× | a.iter() |
| 190 | 0× | .filter_map(|v| v.as_str().and_then(crate::dsse::base64_decode)) |
| 191 | 0× | .filter_map(|b| Identity::decode(&b).ok()) |
| 192 | 0× | .collect() |
| 193 | 0× | }) |
| 194 | 0× | .unwrap_or_default(); |
| 195 | 0× | let linked = chain.windows(2).all(|w| w[0].previous == Some(w[1].hash())) |
| 196 | 0× | && chain.last().is_none_or(|l| l.previous == Some(grantor)); |
| 197 | 0× | if !linked { |
| 198 | 0× | return fail( |
| 199 | 0× | "the hub's author chain does not lead back to the grant's key".to_string(), |
| 200 | | ); |
| 201 | 0× | } |
| 202 | 0× | let keys = home.join("keys"); |
| 203 | 0× | std::fs::create_dir_all(&keys)?; |
| 204 | 0× | for id in &chain { |
| 205 | 0× | self.store.put_identity(id)?; |
| 206 | 0× | let path = keys.join(format!("{}.identity", id.hash().to_hex())); |
| 207 | 0× | if !path.exists() { |
| 208 | 0× | crate::durable::write_atomic(&path, &id.encode())?; |
| 209 | 0× | } |
| 210 | | } |
| 211 | 0× | let newest = chain.first().map(Identity::hash).unwrap_or(grantor); |
| 212 | 0× | crate::durable::write_atomic( |
| 213 | 0× | &keys.join(super::config::AUTHOR_FOR_FILE), |
| 214 | 0× | format!("{}\n", newest.to_hex()).as_bytes(), |
| 215 | 0× | )?; |
| 216 | 0× | Ok(()) |
| 217 | 0× | } |
| 218 | | } |
| 219 | |
|
| 220 | |
|
| 221 | |
|
| 222 | |
|
| 223 | |
|
| 224 | 2× | pub fn home_key(home: &Path) -> Result<Option<(Identity, SecretKey)>> { |
| 225 | 2× | let config = read_config_file(&home.join("config.toml"))?; |
| 226 | 2× | let Some(hex) = config.get("author.key").filter(|v| !v.is_empty()) else { |
| 227 | 1× | return Ok(None); |
| 228 | | }; |
| 229 | 1× | let key = KeyId::from_hex(hex) |
| 230 | 1× | .ok_or_else(|| RepoError::Msg("author.key must be a 64-character hex KeyId".into()))?; |
| 231 | 1× | let keys = home.join("keys"); |
| 232 | 1× | let secret = match fs::read(keys.join(format!("{hex}.secret"))) { |
| 233 | 1× | Ok(b) => b, |
| 234 | 0× | Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), |
| 235 | 0× | Err(e) => return Err(e.into()), |
| 236 | | }; |
| 237 | 1× | let (k, sk) = SecretKey::decode(&secret)?; |
| 238 | 1× | let id = Identity::decode(&fs::read(keys.join(format!("{hex}.identity")))?)?; |
| 239 | 1× | if k != key || id.hash() != key { |
| 240 | 0× | return fail(format!( |
| 241 | | "{} holds another key than author.key", |
| 242 | 0× | keys.display() |
| 243 | | )); |
| 244 | 1× | } |
| 245 | 1× | Ok(Some((id, sk))) |
| 246 | 2× | } |
| 247 | |
|
| 248 | | #[cfg(test)] |
| 249 | | mod tests { |
| 250 | | use super::*; |
| 251 | |
|
| 252 | | #[test] |
| 253 | 1× | fn a_home_s_key_signs_outside_any_repository() { |
| 254 | | static HOME: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new(); |
| 255 | 1× | HOME.get_or_init(|| { |
| 256 | 1× | let d = tempfile::tempdir().unwrap(); |
| 257 | 1× | std::env::set_var("OXY_HOME", d.path()); |
| 258 | 1× | d |
| 259 | 1× | }); |
| 260 | 1× | let dir = tempfile::tempdir().unwrap(); |
| 261 | 1× | let home = dir.path().join("home"); |
| 262 | 1× | assert!(home_key(&home).unwrap().is_none(), "no config, no key"); |
| 263 | 1× | let mut r = Repo::init(&dir.path().join("work")) |
| 264 | 1× | .unwrap() |
| 265 | 1× | .with_home(home.clone()); |
| 266 | 1× | let (key, id) = r |
| 267 | 1× | .create_identity("Andy", "[email protected]", IdentityScope::User) |
| 268 | 1× | .unwrap(); |
| 269 | 1× | let (got, sk) = home_key(&home).unwrap().expect("the home's author key"); |
| 270 | 1× | assert_eq!(got, id); |
| 271 | 1× | let sig = sk.sign(Domain::Grant, &key, &ObjectRef([1; 32])); |
| 272 | 1× | assert!(verify_signature(&id, &sig, Domain::Grant, &ObjectRef([1; 32])).is_ok()); |
| 273 | | |
| 274 | 1× | r.set_user_config("author.signer", "keyless").unwrap(); |
| 275 | 1× | assert!(r.config.signer_keyless && r.config.signer_key.is_none()); |
| 276 | 1× | assert_eq!(r.signing_key(), None); |
| 277 | 1× | let err = r.secret_key().unwrap_err().to_string(); |
| 278 | 1× | assert!(err.contains("keyless.hub nor a default remote"), "{err}"); |
| 279 | 1× | } |
| 280 | | } |