Agents sign keyless: author.signer = keyless (keyless step 5c) c895bc52e4e0
With author.signer = keyless, oxy signs with a fresh key the hub certifies whenever it must sign: it proves the key with the agent's grant token, keeps the key in memory for its certificate's minutes (renewing two minutes before the end), stores the certificate and the hub's published identity, and writes no key to disk. The certificate carries the owner's grant, so the agent's commits count as the owner's. keyless.hub names the hub (default: the default remote's). oxy auth grant keeps the grant token in the credential store (a grant entry beside the hub's token, or the credential helper under its own user name). home_key gives the key an oxy home names, with its secret, so a grant can be signed outside any repository (oh agent grant). The guide and spec §19 describe it; the grant's scope is what certificates under it may give. Refs oxygenhub#241
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-17 18:52 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
c895bc52e4e037ad477508b097a23c089e679f5d005abc0e852403268c722d3b- Depends on
- 05059d434aca 05e71ed6cdf1 2b5ee371060e 445ce913ba56 4dee277c327a 519c7c061bcb 530a0fc650a2 6a81dbab471d 7b9809e67c3d 957fee30826e 982864068a59 98a1620a3840 a2387d547373 ee06a6c557b7 f53cbe7a204e fce44ae9ad29 ff0af1ee299d ff1a64f81c1c ff1f62fee654
- Depended on by
- 2452b4407ab7 f0ef9778e587 e5f8eb2a2006 c96354484bd9
- In bookmarks
- carried-not-removed doctor-trust identical-sides keyless-tags main merge-remote-bookmark paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state
- In tags
- v0.10.0 v0.11.0 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 38 atoms, 1 tree operation, 11 files
Browse the tree at this change
added crates/oxy-core/src/repo/keyless.rs +191 −0
| 1 | + | //! Signing with short-lived keys (spec §14.7, oxygenhub#241): with |
|
| 2 | + | //! `author.signer = keyless`, oxy asks the hub to certify a fresh key |
|
| 3 | + | //! whenever it must sign, with the agent's grant token as the credential, |
|
| 4 | + | //! and keeps the key in memory for its certificate's minutes. Nothing that |
|
| 5 | + | //! signs is ever written to disk: the home holds only the grant token, |
|
| 6 | + | //! which its owner revokes on the hub. The certificate carries the owner's |
|
| 7 | + | //! grant, so what the key signs counts in the owner's name. |
|
| 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 | + | /// A certified key is used until this long before its certificate ends, |
|
| 15 | + | /// so a push started near the end does not outlive it. |
|
| 16 | + | const MARGIN_MS: u64 = 2 * 60 * 1000; |
|
| 17 | + | ||
| 18 | + | impl Repo { |
|
| 19 | + | /// The hub that certifies keys: `keyless.hub`, else the default |
|
| 20 | + | /// remote's. |
|
| 21 | + | fn keyless_hub(&self) -> Result<String> { |
|
| 22 | + | let url = match &self.config.keyless_hub { |
|
| 23 | + | Some(h) => h.clone(), |
|
| 24 | + | None => self |
|
| 25 | + | .config |
|
| 26 | + | .remotes |
|
| 27 | + | .get(&self.default_remote()) |
|
| 28 | + | .cloned() |
|
| 29 | + | .ok_or_else(|| { |
|
| 30 | + | RepoError::Msg( |
|
| 31 | + | "author.signer is keyless, but neither keyless.hub nor a default remote names the hub that certifies keys" |
|
| 32 | + | .into(), |
|
| 33 | + | ) |
|
| 34 | + | })?, |
|
| 35 | + | }; |
|
| 36 | + | hub_of(&url).ok_or_else(|| RepoError::Msg(format!("{url} is not a hub URL"))) |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | /// The key to sign with now: the one certified last while it has more |
|
| 40 | + | /// than a couple of minutes left, else a fresh one the hub certifies. |
|
| 41 | + | pub(crate) fn keyless_secret(&self) -> Result<(KeyId, SecretKey)> { |
|
| 42 | + | let now = Hlc::now().wall_ms; |
|
| 43 | + | if let Some((k, sk, until)) = self.keyless_key.borrow().as_ref() { |
|
| 44 | + | if now + MARGIN_MS < *until { |
|
| 45 | + | return Ok((*k, sk.clone())); |
|
| 46 | + | } |
|
| 47 | + | } |
|
| 48 | + | let hub = self.keyless_hub()?; |
|
| 49 | + | let home = self |
|
| 50 | + | .user_config |
|
| 51 | + | .parent() |
|
| 52 | + | .map(Path::to_path_buf) |
|
| 53 | + | .unwrap_or_else(default_home); |
|
| 54 | + | let creds = Credentials::at(home); |
|
| 55 | + | let token = creds.grant(&hub).map_err(RepoError::Msg)?.ok_or_else(|| { |
|
| 56 | + | RepoError::Msg(format!( |
|
| 57 | + | "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 | + | )) |
|
| 59 | + | })?; |
|
| 60 | + | let options = RemoteOptions { |
|
| 61 | + | token: Some(token.clone()), |
|
| 62 | + | ca: creds.get(&hub).map_err(RepoError::Msg)?.and_then(|e| e.ca), |
|
| 63 | + | ..RemoteOptions::default() |
|
| 64 | + | }; |
|
| 65 | + | let sk = SecretKey::generate(); |
|
| 66 | + | let id = sk.identity(&self.config.author_name, &self.config.author_email, now); |
|
| 67 | + | let key = id.hash(); |
|
| 68 | + | let proof = sk |
|
| 69 | + | .sign_challenge(&key, &certify_challenge(&token)) |
|
| 70 | + | .map_err(|e| RepoError::Msg(e.to_string()))?; |
|
| 71 | + | let body = serde_json::json!({ |
|
| 72 | + | "identity": crate::dsse::base64(&id.encode()), |
|
| 73 | + | "proof": crate::dsse::base64(&crate::cbor::encode(&proof.to_value())), |
|
| 74 | + | "scope": "changes,refs,seal", |
|
| 75 | + | }); |
|
| 76 | + | let (code, answer) = http_json( |
|
| 77 | + | "POST", |
|
| 78 | + | &format!("{hub}/api/v1/keyless/certify"), |
|
| 79 | + | Some(&body.to_string()), |
|
| 80 | + | &options, |
|
| 81 | + | )?; |
|
| 82 | + | let reply: serde_json::Value = serde_json::from_str(&answer).unwrap_or_default(); |
|
| 83 | + | if !(200..300).contains(&code) { |
|
| 84 | + | let why = reply["error"].as_str().unwrap_or(answer.trim()); |
|
| 85 | + | return fail(format!( |
|
| 86 | + | "{hub} would not certify a key (HTTP {code}): {why}" |
|
| 87 | + | )); |
|
| 88 | + | } |
|
| 89 | + | let cert = reply["certificate"] |
|
| 90 | + | .as_str() |
|
| 91 | + | .and_then(crate::dsse::base64_decode) |
|
| 92 | + | .ok_or_else(|| RepoError::Msg(format!("{hub} answered with no certificate")))?; |
|
| 93 | + | let cert = SignedCertificate::decode(&cert) |
|
| 94 | + | .map_err(|e| RepoError::Msg(format!("{hub}'s certificate: {e}")))?; |
|
| 95 | + | if cert.cert.key_id() != key { |
|
| 96 | + | return fail(format!( |
|
| 97 | + | "{hub} certified another key than the one it was asked for" |
|
| 98 | + | )); |
|
| 99 | + | } |
|
| 100 | + | // The issuer's identity, so this repository (and whoever it pushes |
|
| 101 | + | // to) can check the certificate: the hub's own, as it publishes it. |
|
| 102 | + | let issuer = match self.store.get_identity(&cert.cert.issuer) { |
|
| 103 | + | Ok(i) => i, |
|
| 104 | + | Err(_) => { |
|
| 105 | + | let bytes = http_get( |
|
| 106 | + | &format!("{hub}/.well-known/oxyhub-key"), |
|
| 107 | + | &RemoteOptions { |
|
| 108 | + | token: None, |
|
| 109 | + | ..options.clone() |
|
| 110 | + | }, |
|
| 111 | + | )?; |
|
| 112 | + | let i = Identity::decode(&bytes)?; |
|
| 113 | + | if i.hash() != cert.cert.issuer { |
|
| 114 | + | return fail(format!( |
|
| 115 | + | "{hub}'s published key is not the one that signed its certificate" |
|
| 116 | + | )); |
|
| 117 | + | } |
|
| 118 | + | i |
|
| 119 | + | } |
|
| 120 | + | }; |
|
| 121 | + | cert.verify(&issuer) |
|
| 122 | + | .map_err(|e| RepoError::Msg(format!("{hub}'s certificate: {e}")))?; |
|
| 123 | + | self.store.put_identity(&issuer)?; |
|
| 124 | + | self.store.put_identity(&id)?; |
|
| 125 | + | self.store.put_certificate(&cert)?; |
|
| 126 | + | *self.keyless_key.borrow_mut() = Some((key, sk.clone(), cert.cert.expires)); |
|
| 127 | + | Ok((key, sk)) |
|
| 128 | + | } |
|
| 129 | + | } |
|
| 130 | + | ||
| 131 | + | /// The key a home names as its author (`author.key` in its |
|
| 132 | + | /// `config.toml`), with its secret from the home's `keys/`: what signs |
|
| 133 | + | /// outside any repository, such as a grant to an agent (`oh agent grant`). |
|
| 134 | + | /// `None` when the home names no key or does not hold its secret. |
|
| 135 | + | pub fn home_key(home: &Path) -> Result<Option<(Identity, SecretKey)>> { |
|
| 136 | + | let config = read_config_file(&home.join("config.toml"))?; |
|
| 137 | + | let Some(hex) = config.get("author.key").filter(|v| !v.is_empty()) else { |
|
| 138 | + | return Ok(None); |
|
| 139 | + | }; |
|
| 140 | + | let key = KeyId::from_hex(hex) |
|
| 141 | + | .ok_or_else(|| RepoError::Msg("author.key must be a 64-character hex KeyId".into()))?; |
|
| 142 | + | let keys = home.join("keys"); |
|
| 143 | + | let secret = match fs::read(keys.join(format!("{hex}.secret"))) { |
|
| 144 | + | Ok(b) => b, |
|
| 145 | + | Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), |
|
| 146 | + | Err(e) => return Err(e.into()), |
|
| 147 | + | }; |
|
| 148 | + | let (k, sk) = SecretKey::decode(&secret)?; |
|
| 149 | + | let id = Identity::decode(&fs::read(keys.join(format!("{hex}.identity")))?)?; |
|
| 150 | + | if k != key || id.hash() != key { |
|
| 151 | + | return fail(format!( |
|
| 152 | + | "{} holds another key than author.key", |
|
| 153 | + | keys.display() |
|
| 154 | + | )); |
|
| 155 | + | } |
|
| 156 | + | Ok(Some((id, sk))) |
|
| 157 | + | } |
|
| 158 | + | ||
| 159 | + | #[cfg(test)] |
|
| 160 | + | mod tests { |
|
| 161 | + | use super::*; |
|
| 162 | + | ||
| 163 | + | #[test] |
|
| 164 | + | fn a_home_s_key_signs_outside_any_repository() { |
|
| 165 | + | static HOME: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new(); |
|
| 166 | + | HOME.get_or_init(|| { |
|
| 167 | + | let d = tempfile::tempdir().unwrap(); |
|
| 168 | + | std::env::set_var("OXY_HOME", d.path()); |
|
| 169 | + | d |
|
| 170 | + | }); |
|
| 171 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 172 | + | let home = dir.path().join("home"); |
|
| 173 | + | assert!(home_key(&home).unwrap().is_none(), "no config, no key"); |
|
| 174 | + | let mut r = Repo::init(&dir.path().join("work")) |
|
| 175 | + | .unwrap() |
|
| 176 | + | .with_home(home.clone()); |
|
| 177 | + | let (key, id) = r |
|
| 178 | + | .create_identity("Andy", "[email protected]", IdentityScope::User) |
|
| 179 | + | .unwrap(); |
|
| 180 | + | let (got, sk) = home_key(&home).unwrap().expect("the home's author key"); |
|
| 181 | + | assert_eq!(got, id); |
|
| 182 | + | let sig = sk.sign(Domain::Grant, &key, &ObjectRef([1; 32])); |
|
| 183 | + | assert!(verify_signature(&id, &sig, Domain::Grant, &ObjectRef([1; 32])).is_ok()); |
|
| 184 | + | // An agent whose signer is keyless signs nothing until it asks. |
|
| 185 | + | r.set_user_config("author.signer", "keyless").unwrap(); |
|
| 186 | + | assert!(r.config.signer_keyless && r.config.signer_key.is_none()); |
|
| 187 | + | assert_eq!(r.signing_key(), None); |
|
| 188 | + | let err = r.secret_key().unwrap_err().to_string(); |
|
| 189 | + | assert!(err.contains("keyless.hub nor a default remote"), "{err}"); |
|
| 190 | + | } |
|
| 191 | + | } |
modified Cargo.lock +1 −0
| … | 1502 unchanged lines |
||
| 1503 | 1503 | "rayon", |
|
| 1504 | 1504 | "rustls", |
|
| 1505 | 1505 | "rustls-pemfile", |
|
| 1506 | + | "serde_json", |
|
| 1506 | 1507 | "similar", |
|
| 1507 | 1508 | "tempfile", |
|
| 1508 | 1509 | "thiserror 1.0.69", |
|
| … | 1443 unchanged lines |
modified crates/oxy-core/Cargo.toml +1 −0
| … | 5 unchanged lines |
||
| 6 | 6 | description = "Core data model, change graph, and storage for Oxygen" |
|
| 7 | 7 | ||
| 8 | 8 | [dependencies] |
|
| 9 | + | serde_json = "1" |
|
| 9 | 10 | blake3 = { version = "1", features = ["rayon"] } |
|
| 10 | 11 | hex = "0.4" |
|
| 11 | 12 | rand = "0.8" |
|
| … | 20 unchanged lines |
modified crates/oxy-core/src/credentials.rs +91 −2
| … | 82 unchanged lines |
||
| 83 | 83 | pub token: Option<String>, |
|
| 84 | 84 | /// Extra roots to trust for this hub (a self-signed hub). |
|
| 85 | 85 | pub ca: Option<PathBuf>, |
|
| 86 | + | /// An agent's grant token for keyless signing (oxygenhub#241), when it |
|
| 87 | + | /// is in the file (not with a helper). |
|
| 88 | + | pub grant: Option<String>, |
|
| 86 | 89 | } |
|
| 87 | 90 | ||
| 88 | 91 | /// Where a saved token went. |
|
| … | 71 unchanged lines |
||
| 160 | 163 | entry.token = run_helper(h, "get", hub, None)?; |
|
| 161 | 164 | } |
|
| 162 | 165 | } |
|
| 163 | - | if entry.token.is_none() && entry.account.is_none() && entry.ca.is_none() { |
|
| 166 | + | if entry.token.is_none() |
|
| 167 | + | && entry.account.is_none() |
|
| 168 | + | && entry.ca.is_none() |
|
| 169 | + | && entry.grant.is_none() |
|
| 170 | + | { |
|
| 164 | 171 | return Ok(None); |
|
| 165 | 172 | } |
|
| 166 | 173 | Ok(Some(entry)) |
|
| … | 33 unchanged lines |
||
| 200 | 207 | Ok(stored) |
|
| 201 | 208 | } |
|
| 202 | 209 | ||
| 210 | + | /// Save an agent's grant token for `hub` (keyless signing, spec §14.7): |
|
| 211 | + | /// in the file, or the helper under its own user name. |
|
| 212 | + | pub fn save_grant(&self, hub: &str, token: &str) -> Result<Stored, String> { |
|
| 213 | + | let mut all = self.read()?; |
|
| 214 | + | let entry = all.entry(hub.to_string()).or_insert_with(|| HubEntry { |
|
| 215 | + | hub: hub.to_string(), |
|
| 216 | + | ..HubEntry::default() |
|
| 217 | + | }); |
|
| 218 | + | let stored = match self.helper() { |
|
| 219 | + | Some(h) => { |
|
| 220 | + | run_helper_as(&h, "store", hub, GRANT_USER, Some(token))?; |
|
| 221 | + | entry.grant = None; |
|
| 222 | + | Stored::Helper(h) |
|
| 223 | + | } |
|
| 224 | + | None => { |
|
| 225 | + | entry.grant = Some(token.to_string()); |
|
| 226 | + | Stored::File(self.path()) |
|
| 227 | + | } |
|
| 228 | + | }; |
|
| 229 | + | self.write(&all)?; |
|
| 230 | + | Ok(stored) |
|
| 231 | + | } |
|
| 232 | + | ||
| 233 | + | /// The agent grant token kept for `hub`, if any. |
|
| 234 | + | pub fn grant(&self, hub: &str) -> Result<Option<String>, String> { |
|
| 235 | + | if let Some(g) = self.read()?.get(hub).and_then(|e| e.grant.clone()) { |
|
| 236 | + | return Ok(Some(g)); |
|
| 237 | + | } |
|
| 238 | + | match self.helper() { |
|
| 239 | + | Some(h) => run_helper_as(&h, "get", hub, GRANT_USER, None), |
|
| 240 | + | None => Ok(None), |
|
| 241 | + | } |
|
| 242 | + | } |
|
| 243 | + | ||
| 203 | 244 | /// Remove `hub` from the file and the helper; returns what it held, |
|
| 204 | 245 | /// with the token, so the caller can revoke it. |
|
| 205 | 246 | pub fn remove(&self, hub: &str) -> Result<Option<HubEntry>, String> { |
|
| … | 53 unchanged lines |
||
| 259 | 300 | "account" => e.account = Some(v), |
|
| 260 | 301 | "token" => e.token = Some(v), |
|
| 261 | 302 | "ca" => e.ca = Some(PathBuf::from(v)), |
|
| 303 | + | "grant" => e.grant = Some(v), |
|
| 262 | 304 | // Unknown keys are kept out of lookups, not fatal. |
|
| 263 | 305 | _ => {} |
|
| 264 | 306 | } |
|
| … | 17 unchanged lines |
||
| 282 | 324 | } |
|
| 283 | 325 | if let Some(c) = &e.ca { |
|
| 284 | 326 | text.push_str(&format!("ca = {}\n", quote(&c.to_string_lossy()))); |
|
| 327 | + | } |
|
| 328 | + | if let Some(g) = &e.grant { |
|
| 329 | + | text.push_str(&format!("grant = {}\n", quote(g))); |
|
| 285 | 330 | } |
|
| 286 | 331 | } |
|
| 287 | 332 | let path = self.path(); |
|
| … | 116 unchanged lines |
||
| 404 | 449 | hub: &str, |
|
| 405 | 450 | token: Option<&str>, |
|
| 406 | 451 | ) -> Result<Option<String>, String> { |
|
| 452 | + | run_helper_as(helper, action, hub, HELPER_USER, token) |
|
| 453 | + | } |
|
| 454 | + | ||
| 455 | + | /// The helper's user name for an agent's grant token, beside the hub's |
|
| 456 | + | /// own token. |
|
| 457 | + | const GRANT_USER: &str = "oxy-agent-grant"; |
|
| 458 | + | ||
| 459 | + | fn run_helper_as( |
|
| 460 | + | helper: &str, |
|
| 461 | + | action: &str, |
|
| 462 | + | hub: &str, |
|
| 463 | + | user: &str, |
|
| 464 | + | token: Option<&str>, |
|
| 465 | + | ) -> Result<Option<String>, String> { |
|
| 407 | 466 | let (protocol, host) = hub |
|
| 408 | 467 | .split_once("://") |
|
| 409 | 468 | .ok_or_else(|| format!("{hub} is not a hub URL"))?; |
|
| … | 10 unchanged lines |
||
| 420 | 479 | c.arg(format!("credential-{helper}")).arg(action); |
|
| 421 | 480 | c |
|
| 422 | 481 | }; |
|
| 423 | - | let mut input = format!("protocol={protocol}\nhost={host}\nusername={HELPER_USER}\n"); |
|
| 482 | + | let mut input = format!("protocol={protocol}\nhost={host}\nusername={user}\n"); |
|
| 424 | 483 | if let Some(t) = token { |
|
| 425 | 484 | input.push_str(&format!("password={t}\n")); |
|
| 426 | 485 | } |
|
| … | 29 unchanged lines |
||
| 456 | 515 | #[cfg(test)] |
|
| 457 | 516 | mod tests { |
|
| 458 | 517 | use super::*; |
|
| 518 | + | ||
| 519 | + | /// oxygenhub#241: an agent's grant token is kept beside the hub's own |
|
| 520 | + | /// token, owner-only, and read back without disturbing it. |
|
| 521 | + | #[test] |
|
| 522 | + | fn a_grant_token_is_kept_beside_the_hub_token() { |
|
| 523 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 524 | + | let store = Credentials::at(dir.path()); |
|
| 525 | + | let hub = "https://oxygenhub.io"; |
|
| 526 | + | assert_eq!(store.grant(hub).unwrap(), None); |
|
| 527 | + | store.save(hub, "oxyhub_t", Some("andy"), None).unwrap(); |
|
| 528 | + | store.save_grant(hub, "oxyagent_g").unwrap(); |
|
| 529 | + | assert_eq!(store.grant(hub).unwrap().as_deref(), Some("oxyagent_g")); |
|
| 530 | + | let e = store.get(hub).unwrap().unwrap(); |
|
| 531 | + | assert_eq!(e.token.as_deref(), Some("oxyhub_t")); |
|
| 532 | + | assert_eq!(e.account.as_deref(), Some("andy")); |
|
| 533 | + | // A grant alone is an entry too. |
|
| 534 | + | store |
|
| 535 | + | .save_grant("https://elsewhere.example", "oxyagent_h") |
|
| 536 | + | .unwrap(); |
|
| 537 | + | assert!(store.get("https://elsewhere.example").unwrap().is_some()); |
|
| 538 | + | #[cfg(unix)] |
|
| 539 | + | { |
|
| 540 | + | use std::os::unix::fs::PermissionsExt; |
|
| 541 | + | let mode = std::fs::metadata(store.path()) |
|
| 542 | + | .unwrap() |
|
| 543 | + | .permissions() |
|
| 544 | + | .mode(); |
|
| 545 | + | assert_eq!(mode & 0o077, 0, "owner only"); |
|
| 546 | + | } |
|
| 547 | + | } |
|
| 459 | 548 | ||
| 460 | 549 | #[test] |
|
| 461 | 550 | fn hubs_are_scheme_host_and_port() { |
|
| … | 115 unchanged lines |
modified crates/oxy-core/src/repo/config.rs +15 −2
| … | 17 unchanged lines |
||
| 18 | 18 | /// agent sets it to its own key, so its changes carry the human as |
|
| 19 | 19 | /// author and the agent's signature (spec §14.2, agent workflows §2). |
|
| 20 | 20 | pub signer_key: Option<KeyId>, |
|
| 21 | + | /// `author.signer = "keyless"`: sign with fresh keys the hub certifies |
|
| 22 | + | /// under the agent's grant, keeping no key on disk (spec §14.7, |
|
| 23 | + | /// oxygenhub#241). |
|
| 24 | + | pub signer_keyless: bool, |
|
| 25 | + | /// `keyless.hub`: the hub that certifies them (default: the default |
|
| 26 | + | /// remote's hub). |
|
| 27 | + | pub keyless_hub: Option<String>, |
|
| 21 | 28 | /// `core.sign`: sign every change at commit (`true`) or leave signing to |
|
| 22 | 29 | /// push-time seals (`false`, the spec default). |
|
| 23 | 30 | pub sign_changes: bool, |
|
| … | 54 unchanged lines |
||
| 78 | 85 | signer_key: std::env::var("OXY_SIGNER") |
|
| 79 | 86 | .ok() |
|
| 80 | 87 | .and_then(|v| KeyId::from_hex(&v)), |
|
| 88 | + | signer_keyless: std::env::var("OXY_SIGNER").is_ok_and(|v| v == "keyless"), |
|
| 89 | + | keyless_hub: None, |
|
| 81 | 90 | sign_changes: false, |
|
| 82 | 91 | bookmark: "main".into(), |
|
| 83 | 92 | remotes: BTreeMap::new(), |
|
| … | 55 unchanged lines |
||
| 139 | 148 | } |
|
| 140 | 149 | } |
|
| 141 | 150 | "author.signer" => { |
|
| 142 | - | self.signer_key = if value.is_empty() { |
|
| 151 | + | self.signer_keyless = value == "keyless"; |
|
| 152 | + | self.signer_key = if value.is_empty() || self.signer_keyless { |
|
| 143 | 153 | None |
|
| 144 | 154 | } else { |
|
| 145 | 155 | Some(KeyId::from_hex(value).ok_or_else(|| { |
|
| 146 | - | RepoError::Msg("author.signer must be a 64-character hex KeyId".into()) |
|
| 156 | + | RepoError::Msg( |
|
| 157 | + | "author.signer must be a 64-character hex KeyId, or \"keyless\"".into(), |
|
| 158 | + | ) |
|
| 147 | 159 | })?) |
|
| 148 | 160 | } |
|
| 149 | 161 | } |
|
| … | 5 unchanged lines |
||
| 155 | 167 | } |
|
| 156 | 168 | } |
|
| 157 | 169 | "workspace.bookmark" => self.bookmark = value.into(), |
|
| 170 | + | "keyless.hub" => self.keyless_hub = (!value.is_empty()).then(|| value.to_string()), |
|
| 158 | 171 | // Read by the credential store from the user file. |
|
| 159 | 172 | "auth.helper" => {} |
|
| 160 | 173 | "core.op-retention" => self.op_retention_days = parse_days(value)?, |
|
| … | 173 unchanged lines |
modified crates/oxy-core/src/repo/identity.rs +9 −1
| … | 327 unchanged lines |
||
| 328 | 328 | /// The key this repository signs with: `author.signer` when set (an |
|
| 329 | 329 | /// agent's own key, acting for the author), else `author.key`. |
|
| 330 | 330 | pub fn signing_key(&self) -> Option<KeyId> { |
|
| 331 | + | if self.config.signer_keyless { |
|
| 332 | + | // The key certified last, once this command has signed. |
|
| 333 | + | return self.keyless_key.borrow().as_ref().map(|(k, _, _)| *k); |
|
| 334 | + | } |
|
| 331 | 335 | self.config.signer_key.or(self.config.author_key) |
|
| 332 | 336 | } |
|
| 333 | 337 | ||
| … | 2 unchanged lines |
||
| 336 | 340 | /// so a repository that has never seen it (a fresh clone) can store it |
|
| 337 | 341 | /// when it first signs. |
|
| 338 | 342 | pub fn secret_key(&self) -> Result<Option<(KeyId, SecretKey)>> { |
|
| 343 | + | if self.config.signer_keyless { |
|
| 344 | + | return self.keyless_secret().map(Some); |
|
| 345 | + | } |
|
| 339 | 346 | let Some(key) = self.signing_key() else { |
|
| 340 | 347 | return Ok(None); |
|
| 341 | 348 | }; |
|
| … | 355 unchanged lines |
||
| 697 | 704 | pub(crate) fn seals_for_author(&self, author: &KeyId, signer: &KeyId) -> bool { |
|
| 698 | 705 | author == signer |
|
| 699 | 706 | || author.is_zero() |
|
| 700 | - | || (self.config.signer_key.is_some() && self.config.author_key == Some(*author)) |
|
| 707 | + | || ((self.config.signer_key.is_some() || self.config.signer_keyless) |
|
| 708 | + | && self.config.author_key == Some(*author)) |
|
| 701 | 709 | } |
|
| 702 | 710 | ||
| 703 | 711 | /// The changes of the current bookmark this identity can authenticate |
|
| … | 38 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +10 −0
| … | 115 unchanged lines |
||
| 116 | 116 | mod hosted; |
|
| 117 | 117 | mod identity; |
|
| 118 | 118 | mod inspect; |
|
| 119 | + | mod keyless; |
|
| 119 | 120 | mod ops; |
|
| 120 | 121 | mod policy; |
|
| 121 | 122 | mod prompt; |
|
| … | 21 unchanged lines |
||
| 143 | 144 | pub use hosted::FileEdit; |
|
| 144 | 145 | pub use identity::{Rotation, SignatureStatus}; |
|
| 145 | 146 | pub use inspect::{BisectStep, BlameLine, Description}; |
|
| 147 | + | pub use keyless::home_key; |
|
| 146 | 148 | pub use ops::{RestoreReport, RevertReport, Undone, ViewChange}; |
|
| 147 | 149 | pub use policy::PolicyUpdate; |
|
| 148 | 150 | pub use prompt::{PromptInfo, DEFAULT_PROMPT_FORMAT, GLYPH_PROMPT_FORMAT}; |
|
| … | 103 unchanged lines |
||
| 252 | 254 | /// set, accepting a reference update signed by a short-lived key also |
|
| 253 | 255 | /// records the host's acceptance and its time. |
|
| 254 | 256 | pub(crate) acceptor: Option<std::sync::Arc<(Identity, crate::identity::SecretKey)>>, |
|
| 257 | + | /// With `author.signer = keyless`, the fresh key the hub last |
|
| 258 | + | /// certified and when its certificate ends (spec §14.7): kept in memory |
|
| 259 | + | /// only, for one command. |
|
| 260 | + | pub(crate) keyless_key: std::cell::RefCell<Option<(KeyId, crate::identity::SecretKey, u64)>>, |
|
| 255 | 261 | /// Oxygen-pinned subrepos skipped by the running Git export. |
|
| 256 | 262 | pub(crate) git_export_skipped: std::cell::Cell<usize>, |
|
| 257 | 263 | /// The manifest as last read, with the file identity it was read from, |
|
| … | 118 unchanged lines |
||
| 376 | 382 | let store = open_objects(&oxy, &config)?; |
|
| 377 | 383 | let repo = Repo { |
|
| 378 | 384 | acceptor: None, |
|
| 385 | + | keyless_key: Default::default(), |
|
| 379 | 386 | last_change: Default::default(), |
|
| 380 | 387 | at_op: None, |
|
| 381 | 388 | walked_dirs: Default::default(), |
|
| … | 59 unchanged lines |
||
| 441 | 448 | let store = open_objects(&oxy, &config)?; |
|
| 442 | 449 | let repo = Repo { |
|
| 443 | 450 | acceptor: None, |
|
| 451 | + | keyless_key: Default::default(), |
|
| 444 | 452 | last_change: Default::default(), |
|
| 445 | 453 | at_op: None, |
|
| 446 | 454 | walked_dirs: Default::default(), |
|
| … | 73 unchanged lines |
||
| 520 | 528 | let store = open_objects(&shared, &config)?; |
|
| 521 | 529 | let mut repo = Repo { |
|
| 522 | 530 | acceptor: None, |
|
| 531 | + | keyless_key: Default::default(), |
|
| 523 | 532 | last_change: Default::default(), |
|
| 524 | 533 | at_op: None, |
|
| 525 | 534 | walked_dirs: Default::default(), |
|
| … | 28 unchanged lines |
||
| 554 | 563 | let store = open_objects(&oxy, &config)?; |
|
| 555 | 564 | let mut repo = Repo { |
|
| 556 | 565 | acceptor: None, |
|
| 566 | + | keyless_key: Default::default(), |
|
| 557 | 567 | last_change: Default::default(), |
|
| 558 | 568 | at_op: None, |
|
| 559 | 569 | walked_dirs: Default::default(), |
|
| … | 2120 unchanged lines |
modified docs/guide.md +21 −1
| … | 465 unchanged lines |
||
| 466 | 466 | lists them, `oxy verify` names the agent, and its own `oxy push` is |
|
| 467 | 467 | refused under any policy, so you land its work. |
|
| 468 | 468 | ||
| 469 | + | **Keyless agents** (on an OxygenHub) hold no key at all. You give the |
|
| 470 | + | agent a grant instead, signed by your key, and the hub certifies a fresh |
|
| 471 | + | key for it each time it signs: |
|
| 472 | + | ||
| 473 | + | ``` |
|
| 474 | + | oh agent grant claude --for 30d # your shell: prints the agent's token, once |
|
| 475 | + | oxy auth grant < token # the agent's shell (its own OXY_HOME) |
|
| 476 | + | oxy config --user author.key <YOUR KeyId> |
|
| 477 | + | oxy config --user author.signer keyless |
|
| 478 | + | ``` |
|
| 479 | + | ||
| 480 | + | Each certificate lasts ten minutes and carries your grant, so the |
|
| 481 | + | agent's commits count as yours, and a push seals them with the fresh |
|
| 482 | + | key. A repository with a trust policy also names the agent: |
|
| 483 | + | `oxy policy grant-subject agent:claude@YOU --issuer hub.identity --scope |
|
| 484 | + | changes` (add `refs` to let it push), with the hub's identity from |
|
| 485 | + | `/.well-known/oxyhub-key`. `oh agent list` shows your grants and `oh |
|
| 486 | + | agent revoke ID` stops one at once; there is no key to rotate. |
|
| 487 | + | ||
| 469 | 488 | ``` |
|
| 470 | 489 | oxy identity revoke # retire your own key |
|
| 471 | 490 | oxy identity revoke 3f2a… --reason "laptop stolen" # any key, if you hold a root |
|
| … | 141 unchanged lines |
||
| 613 | 632 | | Key | Meaning | |
|
| 614 | 633 | |---|---| |
|
| 615 | 634 | | `author.name`, `author.email`, `author.key` | who you are; `key` is set by `identity create` | |
|
| 616 | - | | `author.signer` | the key that signs instead of `author.key`: an agent's own key, which your identity lists as a delegate (also `$OXY_SIGNER`) | |
|
| 635 | + | | `author.signer` | the key that signs instead of `author.key`: an agent's own key, which your identity lists as a delegate; or `keyless`, for fresh keys the hub certifies under the agent's grant (also `$OXY_SIGNER`) | |
|
| 636 | + | | `keyless.hub` | the hub that certifies keyless keys (default: the default remote's hub) | |
|
| 617 | 637 | | `core.sign` | `change` to sign every commit; otherwise seals at push | |
|
| 618 | 638 | | `core.op-retention` | days for which old operations keep their states alive (90) | |
|
| 619 | 639 | | `core.gc-grace` | days an unreachable object survives (14) | |
|
| … | 59 unchanged lines |
modified docs/spec.md +2 −2
| … | 744 unchanged lines |
||
| 745 | 745 | grantor [1]: KeyId // the author, whose key signs the grant |
|
| 746 | 746 | subject [2]: text // "agent:NAME@ACCOUNT" |
|
| 747 | 747 | issuer [3]: KeyId // whose certificates for the subject count |
|
| 748 | - | scope [4]: [text] // changes, seal |
|
| 748 | + | scope [4]: [text] // what certificates under it may give: changes, seal, refs |
|
| 749 | 749 | not_before [5]: u64 // wall milliseconds |
|
| 750 | 750 | expires [6]: u64 |
|
| 751 | 751 | } |
|
| … | 115 unchanged lines |
||
| 867 | 867 | | `.oxyignore` | Ignore rules. Gitignore syntax and semantics, per directory, plus `.oxy/info/ignore` and the global ignore file. A directory's `.gitignore` and `.ignore` files are read too, with lower precedence than its `.oxyignore` (a `!` rule there re-includes what they exclude), so a project coming from Git leaves out what Git did. Rules apply to untracked paths only: a tracked path stays tracked, and its edits are seen, until it is deleted or removed from the state. | |
|
| 868 | 868 | | `.oxyattributes` | Per-path attributes: `binary` (force opaque), `text` (text unless NUL bytes), `eol=lf|crlf` (store LF; check out as given), `nocompress` (chunks packed raw), `lockable` (read-only until `oxy lock`), `filter=<name>`. Gitattributes syntax: a pattern without `/` matches basenames below the file's directory, one with `/` is anchored to it; later rules and deeper files override. The attributes in force for a change are those in the files being committed. | |
|
| 869 | 869 | | `.oxy/config` | Repository-local settings, TOML. | |
|
| 870 | - | | `~/.config/oxy/config.toml` | User settings, the same keys as `.oxy/config`: `author.name`, `author.email`, `author.key`, `author.signer` (the key that signs and seals in place of `author.key`, which must list it as a delegate: an agent's own key; also `$OXY_SIGNER`), `core.sign`, and `remote.<name>.token`; also `auth.helper`. Overridden by `$OXY_HOME/config.toml` when `$OXY_HOME` is set. | |
|
| 870 | + | | `~/.config/oxy/config.toml` | User settings, the same keys as `.oxy/config`: `author.name`, `author.email`, `author.key`, `author.signer` (the key that signs and seals in place of `author.key`, which must list it as a delegate: an agent's own key; or `keyless`, for fresh keys the hub certifies under an agent's grant, §14.7, from the grant token `oxy auth grant` keeps; also `$OXY_SIGNER`), `keyless.hub` (the hub that certifies them; default the default remote's), `core.sign`, and `remote.<name>.token`; also `auth.helper`. Overridden by `$OXY_HOME/config.toml` when `$OXY_HOME` is set. | |
|
| 871 | 871 | | `~/.config/oxy/credentials.toml` | Tokens from `oxy auth login`, one `[scheme://host[:port]]` section per hub with `token`, `account` and `ca`. Written owner-only; ignored when others can read it. With `auth.helper` set, the token is kept by that helper (Git's credential-helper protocol, user name `oxy-token`) and the section holds the rest. | |
|
| 872 | 872 | ||
| 873 | 873 | Settings resolve in order: the repository file overrides the user file, which overrides environment variables (`OXY_AUTHOR_NAME`, `OXY_AUTHOR_EMAIL`, `OXY_TOKEN`) and built-in defaults. A remote's token and CA without `remote.<name>.token` or `.ca` come from `OXY_TOKEN` and `OXY_CA`, then from `credentials.toml` for the remote's hub, then from a remote on the same hub in an enclosing repository's `.oxy/config`. A client MUST NOT send a token over plain `http` except to a loopback address, and MUST NOT forward it across a redirect. `oxy config` writes the repository file; `oxy config --user` writes the user file. Secret keys live beside the user file in `keys/`, one `<KeyId>.secret` and one `<KeyId>.identity` per key, so any repository the user works in can store the identity object when it first signs. |
|
| … | 230 unchanged lines |
modified src/cli/auth.rs +39 −0
| … | 211 unchanged lines |
||
| 212 | 212 | Ok(0) |
|
| 213 | 213 | } |
|
| 214 | 214 | ||
| 215 | + | /// `oxy auth grant`: keep an agent's grant token (oxygenhub#241) for |
|
| 216 | + | /// keyless signing, read from standard input. |
|
| 217 | + | pub fn grant(hub: Option<&str>) -> Result<i32> { |
|
| 218 | + | use std::io::IsTerminal; |
|
| 219 | + | let hub = resolve_hub(hub)?; |
|
| 220 | + | if !token_allowed(&hub) { |
|
| 221 | + | bail!("{hub} is plain http: a token would cross the network unencrypted; use its https address"); |
|
| 222 | + | } |
|
| 223 | + | if std::io::stdin().is_terminal() { |
|
| 224 | + | eprint!("Agent grant token for {hub}: "); |
|
| 225 | + | } |
|
| 226 | + | let mut line = String::new(); |
|
| 227 | + | std::io::stdin() |
|
| 228 | + | .lock() |
|
| 229 | + | .read_line(&mut line) |
|
| 230 | + | .context("reading the token from standard input")?; |
|
| 231 | + | let token = line.trim(); |
|
| 232 | + | if !token.starts_with("oxyagent_") { |
|
| 233 | + | bail!("that is not an agent grant token (they start oxyagent_; `oh agent grant NAME` makes one)"); |
|
| 234 | + | } |
|
| 235 | + | let stored = Credentials::user() |
|
| 236 | + | .save_grant(&hub, token) |
|
| 237 | + | .map_err(|e| anyhow::anyhow!("{e}"))?; |
|
| 238 | + | println!( |
|
| 239 | + | "{} for {}", |
|
| 240 | + | ui::done("Kept the agent grant"), |
|
| 241 | + | ui::path(&hub) |
|
| 242 | + | ); |
|
| 243 | + | println!( |
|
| 244 | + | " {} {}", |
|
| 245 | + | ui::note(), |
|
| 246 | + | ui::dim(format!( |
|
| 247 | + | "token in {}; sign with it: oxy config author.signer keyless", |
|
| 248 | + | where_stored(&stored) |
|
| 249 | + | )) |
|
| 250 | + | ); |
|
| 251 | + | Ok(0) |
|
| 252 | + | } |
|
| 253 | + | ||
| 215 | 254 | /// The start of a token, enough to tell tokens apart and no more. |
|
| 216 | 255 | fn masked(token: &str) -> String { |
|
| 217 | 256 | let shown: String = token.chars().take(10).collect(); |
|
| … | 128 unchanged lines |
modified src/cli/mod.rs +10 −0
| … | 682 unchanged lines |
||
| 683 | 683 | /// The hub (default: this checkout's origin, or the only one) |
|
| 684 | 684 | hub: Option<String>, |
|
| 685 | 685 | }, |
|
| 686 | + | /// Keep an agent grant token (from `oh agent grant`) for keyless |
|
| 687 | + | /// signing: with `author.signer = keyless`, commits and pushes are |
|
| 688 | + | /// signed by fresh keys the hub certifies, in the grantor's name |
|
| 689 | + | /// (the token is read from standard input, so it stays out of the |
|
| 690 | + | /// shell's history) |
|
| 691 | + | Grant { |
|
| 692 | + | /// The hub (default: this checkout's origin) |
|
| 693 | + | hub: Option<String>, |
|
| 694 | + | }, |
|
| 686 | 695 | } |
|
| 687 | 696 | ||
| 688 | 697 | #[derive(Subcommand)] |
|
| … | 590 unchanged lines |
||
| 1279 | 1288 | } => auth::login(hub.as_deref(), with_token, ca), |
|
| 1280 | 1289 | AuthCmd::Status => auth::status(), |
|
| 1281 | 1290 | AuthCmd::Logout { hub } => auth::logout(hub.as_deref()), |
|
| 1291 | + | AuthCmd::Grant { hub } => auth::grant(hub.as_deref()), |
|
| 1282 | 1292 | }, |
|
| 1283 | 1293 | Cmd::Submodule { cmd } => match cmd { |
|
| 1284 | 1294 | SubmoduleCmd::Add { |
|
| … | 216 unchanged lines |