Scoped, expiring delegates, and revocation by the delegator 7ba155f20aa0
An identity's delegates are entries {key, scope, expires}. The scope
is any of changes (sign changes whose author is the delegator), seal
(seal them) and refs (attest references and tags as the delegator
would); a delegation past its expiry authenticates nothing made after
it. An AI agent is added with --scope changes, so its signature
authenticates what it makes but a reference or tag it attests, or a
seal it signs, is refused: it can never land on a protected bookmark,
even when its owner could. A host that signs and lands for a user
gets changes,refs. A bare KeyId in an older identity means every
scope, so what is already stored still reads.
oxy identity delegate add --scope --expires (30m, 8h, 7d, days);
delegate list and identity show print scope and expiry; oxy identity
revoke accepts a key you delegated to, not only your own or a root's,
and re-issues your identity without it.
Spec §14.2, §14.4, §14.5; guide §9; the agent workflows doc marks
both items done. Tests: a changes-only delegate's change lands when
its owner attests and not when it does; its seal is refused; an
expired delegation is refused; the delegator may revoke it.
closes #67, refs #64
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-04 02:43 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
7ba155f20aa080b927154967d98e3d13a6cde4c1c092df385a728adad1c3b5e9- Depends on
- 05e71ed6cdf1 132b884b9a8d 1983686fb7a7 28fc23d7706a 3526d339519f 648724d8e0de 75167672ebcd 83e0677b07fe 84b9e9ac0ab4 86e682c242c6 982864068a59 b277870b0749 d42369abf180 eae595568832 ee06a6c557b7 f8c695764c95 fce44ae9ad29 ff0af1ee299d
- Depended on by
- d36ba2f79f95 028545dfa7a4 a2387d547373 f53cbe7a204e f0a517d28237 3a6effc74835 02e691881a4e e5d9b0513f3e 68a924846379 614f55e14eee 5cde7882d767 445ce913ba56 ff1a64f81c1c
- In bookmarks
- agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
- In tags
- v0.10.0 v0.11.0 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 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
- 74 atoms, 0 tree operations, 13 files
Browse the tree at this change
modified README.md +3 −2
| … | 69 unchanged lines |
||
| 70 | 70 | commits on top of them, and `oxy git export <dir>` writes a plain Git mirror. |
|
| 71 | 71 | ||
| 72 | 72 | Keys have a lifecycle: `oxy identity rotate` moves to a fresh key pair (the policy follows), |
|
| 73 | - | `oxy identity renew --expires 365` sets an expiry, `oxy identity delegate add bot.identity` |
|
| 74 | - | lets another key sign for you, and `oxy identity revoke` retires a key. With several root |
|
| 73 | + | `oxy identity renew --expires 365` sets an expiry, `oxy identity delegate add agent.identity --scope changes --expires 8h` |
|
| 74 | + | lets another key sign changes for you (an AI agent, say) for a while without ever being able |
|
| 75 | + | to move a bookmark, and `oxy identity revoke` retires a key. With several root |
|
| 75 | 76 | keys, `oxy policy root add|remove|threshold` and `oxy policy sign` gather the required signatures |
|
| 76 | 77 | through proposal files. |
|
| 77 | 78 | ||
| … | 15 unchanged lines |
modified crates/oxy-core/src/identity.rs +220 −19
| … | 25 unchanged lines |
||
| 26 | 26 | /// `alg` value for Ed25519 + ML-DSA-65. |
|
| 27 | 27 | pub const ALG_COMPOSITE: u64 = 1; |
|
| 28 | 28 | ||
| 29 | + | /// What a delegate may do on the delegator's behalf (spec §14.2): a set of |
|
| 30 | + | /// `changes` (sign individual changes), `seal` (seal batches of them) and |
|
| 31 | + | /// `refs` (reference and tag attestations). An agent gets `changes` alone, |
|
| 32 | + | /// so it can never move a reference, whatever its owner may do. |
|
| 33 | + | #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] |
|
| 34 | + | pub struct Scope(u8); |
|
| 35 | + | ||
| 36 | + | impl Scope { |
|
| 37 | + | pub const NONE: Scope = Scope(0); |
|
| 38 | + | pub const CHANGES: Scope = Scope(1); |
|
| 39 | + | pub const REFS: Scope = Scope(2); |
|
| 40 | + | pub const SEAL: Scope = Scope(4); |
|
| 41 | + | /// Everything: what a delegate could do before scopes existed. |
|
| 42 | + | pub const ALL: Scope = Scope(7); |
|
| 43 | + | const NAMES: [(Scope, &'static str); 3] = [ |
|
| 44 | + | (Scope::CHANGES, "changes"), |
|
| 45 | + | (Scope::REFS, "refs"), |
|
| 46 | + | (Scope::SEAL, "seal"), |
|
| 47 | + | ]; |
|
| 48 | + | ||
| 49 | + | pub fn has(self, other: Scope) -> bool { |
|
| 50 | + | self.0 & other.0 == other.0 |
|
| 51 | + | } |
|
| 52 | + | pub fn is_empty(self) -> bool { |
|
| 53 | + | self.0 == 0 |
|
| 54 | + | } |
|
| 55 | + | /// The names in the set, in the order they are written. |
|
| 56 | + | pub fn names(self) -> Vec<&'static str> { |
|
| 57 | + | Self::NAMES |
|
| 58 | + | .iter() |
|
| 59 | + | .filter(|(s, _)| self.has(*s)) |
|
| 60 | + | .map(|(_, n)| *n) |
|
| 61 | + | .collect() |
|
| 62 | + | } |
|
| 63 | + | fn named(name: &str) -> Option<Scope> { |
|
| 64 | + | Self::NAMES |
|
| 65 | + | .iter() |
|
| 66 | + | .find(|(_, n)| *n == name) |
|
| 67 | + | .map(|(s, _)| *s) |
|
| 68 | + | } |
|
| 69 | + | /// `changes,refs` (also with spaces); `all` is every scope. |
|
| 70 | + | pub fn parse(s: &str) -> Result<Scope, String> { |
|
| 71 | + | let mut out = Scope::NONE; |
|
| 72 | + | for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) { |
|
| 73 | + | if part == "all" { |
|
| 74 | + | return Ok(Scope::ALL); |
|
| 75 | + | } |
|
| 76 | + | match Self::named(part) { |
|
| 77 | + | Some(sc) => out = out | sc, |
|
| 78 | + | None => return Err(format!("unknown scope {part:?}: use changes, refs or seal")), |
|
| 79 | + | } |
|
| 80 | + | } |
|
| 81 | + | if out.is_empty() { |
|
| 82 | + | return Err("a delegate needs at least one scope: changes, refs or seal".into()); |
|
| 83 | + | } |
|
| 84 | + | Ok(out) |
|
| 85 | + | } |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | impl std::ops::BitOr for Scope { |
|
| 89 | + | type Output = Scope; |
|
| 90 | + | fn bitor(self, rhs: Scope) -> Scope { |
|
| 91 | + | Scope(self.0 | rhs.0) |
|
| 92 | + | } |
|
| 93 | + | } |
|
| 94 | + | ||
| 95 | + | impl std::fmt::Display for Scope { |
|
| 96 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
|
| 97 | + | f.write_str(&self.names().join(",")) |
|
| 98 | + | } |
|
| 99 | + | } |
|
| 100 | + | ||
| 101 | + | impl std::fmt::Debug for Scope { |
|
| 102 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
|
| 103 | + | write!(f, "Scope({self})") |
|
| 104 | + | } |
|
| 105 | + | } |
|
| 106 | + | ||
| 107 | + | /// A key allowed to act for an identity, within `scope`, until `expires` |
|
| 108 | + | /// (wall milliseconds; `None` for as long as the delegation stands). |
|
| 109 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| 110 | + | pub struct Delegate { |
|
| 111 | + | pub key: KeyId, |
|
| 112 | + | pub scope: Scope, |
|
| 113 | + | pub expires: Option<u64>, |
|
| 114 | + | } |
|
| 115 | + | ||
| 116 | + | impl Delegate { |
|
| 117 | + | /// A delegate with every scope and no expiry of its own. |
|
| 118 | + | pub fn full(key: KeyId) -> Delegate { |
|
| 119 | + | Delegate { |
|
| 120 | + | key, |
|
| 121 | + | scope: Scope::ALL, |
|
| 122 | + | expires: None, |
|
| 123 | + | } |
|
| 124 | + | } |
|
| 125 | + | /// Whether the delegation covers `scope` at `at_ms`. |
|
| 126 | + | pub fn allows(&self, scope: Scope, at_ms: u64) -> bool { |
|
| 127 | + | self.scope.has(scope) && !self.expires.is_some_and(|e| at_ms > e) |
|
| 128 | + | } |
|
| 129 | + | fn to_value(self) -> Value { |
|
| 130 | + | Value::map([ |
|
| 131 | + | (0, Value::Bytes(self.key.0.to_vec())), |
|
| 132 | + | ( |
|
| 133 | + | 1, |
|
| 134 | + | Value::Array( |
|
| 135 | + | self.scope |
|
| 136 | + | .names() |
|
| 137 | + | .into_iter() |
|
| 138 | + | .map(|n| Value::Text(n.to_string())) |
|
| 139 | + | .collect(), |
|
| 140 | + | ), |
|
| 141 | + | ), |
|
| 142 | + | (2, self.expires.map(Value::Uint).unwrap_or(Value::Null)), |
|
| 143 | + | ]) |
|
| 144 | + | } |
|
| 145 | + | /// An entry, or a bare KeyId as identities carried before scopes |
|
| 146 | + | /// existed, which meant every scope. |
|
| 147 | + | fn from_value(v: &Value) -> Result<Delegate, ObjectError> { |
|
| 148 | + | if let Value::Bytes(_) = v { |
|
| 149 | + | return Ok(Delegate::full(oref(v, "delegate")?)); |
|
| 150 | + | } |
|
| 151 | + | let m = v |
|
| 152 | + | .as_map() |
|
| 153 | + | .ok_or_else(|| ObjectError::Schema("delegate: expected map".into()))?; |
|
| 154 | + | let key = oref(field(m, 0, "delegate key")?, "delegate key")?; |
|
| 155 | + | let mut scope = Scope::NONE; |
|
| 156 | + | for n in array(field(m, 1, "delegate scope")?, "delegate scope")? { |
|
| 157 | + | let n = text(n, "delegate scope")?; |
|
| 158 | + | scope = scope |
|
| 159 | + | | Scope::named(n) |
|
| 160 | + | .ok_or_else(|| ObjectError::Schema(format!("delegate: unknown scope {n:?}")))?; |
|
| 161 | + | } |
|
| 162 | + | let expires = match field(m, 2, "delegate expires")? { |
|
| 163 | + | v if v.is_null() => None, |
|
| 164 | + | v => Some(uint(v, "delegate expires")?), |
|
| 165 | + | }; |
|
| 166 | + | Ok(Delegate { |
|
| 167 | + | key, |
|
| 168 | + | scope, |
|
| 169 | + | expires, |
|
| 170 | + | }) |
|
| 171 | + | } |
|
| 172 | + | } |
|
| 173 | + | ||
| 29 | 174 | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 30 | 175 | pub struct Identity { |
|
| 31 | 176 | pub ed25519_pk: [u8; ED25519_PK_LEN], |
|
| … | 3 unchanged lines |
||
| 35 | 180 | pub created: u64, |
|
| 36 | 181 | pub expires: Option<u64>, |
|
| 37 | 182 | pub previous: Option<KeyId>, |
|
| 38 | - | pub delegates: Vec<KeyId>, |
|
| 183 | + | pub delegates: Vec<Delegate>, |
|
| 184 | + | } |
|
| 185 | + | ||
| 186 | + | /// Sorted by key, one entry per key. |
|
| 187 | + | fn tidy(mut delegates: Vec<Delegate>) -> Vec<Delegate> { |
|
| 188 | + | delegates.sort_by_key(|d| d.key); |
|
| 189 | + | delegates.dedup_by(|a, b| a.key == b.key); |
|
| 190 | + | delegates |
|
| 39 | 191 | } |
|
| 40 | 192 | ||
| 41 | 193 | impl Identity { |
|
| 194 | + | /// The delegation to `key`, if any. |
|
| 195 | + | pub fn delegate(&self, key: &KeyId) -> Option<&Delegate> { |
|
| 196 | + | self.delegates.iter().find(|d| d.key == *key) |
|
| 197 | + | } |
|
| 198 | + | /// Whether `key` may act for this identity within `scope` at `at_ms`. |
|
| 199 | + | pub fn delegates_to(&self, key: &KeyId, scope: Scope, at_ms: u64) -> bool { |
|
| 200 | + | self.delegate(key).is_some_and(|d| d.allows(scope, at_ms)) |
|
| 201 | + | } |
|
| 42 | 202 | pub fn to_value(&self) -> Value { |
|
| 43 | 203 | Value::map([ |
|
| 44 | 204 | (0, Value::Uint(FORMAT_VERSION)), |
|
| … | 11 unchanged lines |
||
| 56 | 216 | ), |
|
| 57 | 217 | ( |
|
| 58 | 218 | 8, |
|
| 59 | - | Value::Array( |
|
| 60 | - | self.delegates |
|
| 61 | - | .iter() |
|
| 62 | - | .map(|d| Value::Bytes(d.0.to_vec())) |
|
| 63 | - | .collect(), |
|
| 64 | - | ), |
|
| 219 | + | Value::Array(self.delegates.iter().map(|d| d.to_value()).collect()), |
|
| 65 | 220 | ), |
|
| 66 | 221 | ]) |
|
| 67 | 222 | } |
|
| … | 30 unchanged lines |
||
| 98 | 253 | oref(v, "previous").map(Some) |
|
| 99 | 254 | } |
|
| 100 | 255 | }; |
|
| 101 | - | let mut delegates = array(field(m, 8, "delegates")?, "delegates")? |
|
| 102 | - | .iter() |
|
| 103 | - | .map(|d| oref(d, "delegate")) |
|
| 104 | - | .collect::<Result<Vec<_>, _>>()?; |
|
| 105 | - | delegates.sort(); |
|
| 106 | - | delegates.dedup(); |
|
| 256 | + | let delegates = tidy( |
|
| 257 | + | array(field(m, 8, "delegates")?, "delegates")? |
|
| 258 | + | .iter() |
|
| 259 | + | .map(Delegate::from_value) |
|
| 260 | + | .collect::<Result<Vec<_>, _>>()?, |
|
| 261 | + | ); |
|
| 107 | 262 | Ok(Identity { |
|
| 108 | 263 | ed25519_pk: ed.try_into().unwrap(), |
|
| 109 | 264 | mldsa65_pk: ml.to_vec(), |
|
| … | 13 unchanged lines |
||
| 123 | 278 | ||
| 124 | 279 | /// A successor identity with the same keys, `previous` set to this |
|
| 125 | 280 | /// identity's KeyId, and the given delegates and expiry (spec §14.2). |
|
| 126 | - | pub fn successor(&self, delegates: Vec<KeyId>, expires: Option<u64>, created: u64) -> Identity { |
|
| 127 | - | let mut delegates = delegates; |
|
| 128 | - | delegates.sort(); |
|
| 129 | - | delegates.dedup(); |
|
| 281 | + | pub fn successor( |
|
| 282 | + | &self, |
|
| 283 | + | delegates: Vec<Delegate>, |
|
| 284 | + | expires: Option<u64>, |
|
| 285 | + | created: u64, |
|
| 286 | + | ) -> Identity { |
|
| 130 | 287 | Identity { |
|
| 131 | 288 | previous: Some(self.hash()), |
|
| 132 | - | delegates, |
|
| 289 | + | delegates: tidy(delegates), |
|
| 133 | 290 | expires, |
|
| 134 | 291 | created, |
|
| 135 | 292 | ..self.clone() |
|
| … | 257 unchanged lines |
||
| 393 | 550 | id.hash() |
|
| 394 | 551 | ); |
|
| 395 | 552 | let mut other = id.clone(); |
|
| 396 | - | other.delegates.push(ObjectRef([9; 32])); |
|
| 553 | + | other.delegates.push(Delegate::full(ObjectRef([9; 32]))); |
|
| 397 | 554 | assert_ne!(other.hash(), id.hash()); |
|
| 555 | + | } |
|
| 556 | + | ||
| 557 | + | #[test] |
|
| 558 | + | fn delegates_carry_scope_and_expiry_and_bare_keys_mean_every_scope() { |
|
| 559 | + | let sk = fixed_key(); |
|
| 560 | + | let mut id = sk.identity("Ada", "[email protected]", 1_700_000_000_000); |
|
| 561 | + | let agent = ObjectRef([7; 32]); |
|
| 562 | + | let host = ObjectRef([3; 32]); |
|
| 563 | + | id.delegates = vec![ |
|
| 564 | + | Delegate { |
|
| 565 | + | key: agent, |
|
| 566 | + | scope: Scope::CHANGES, |
|
| 567 | + | expires: Some(2_000), |
|
| 568 | + | }, |
|
| 569 | + | Delegate { |
|
| 570 | + | key: host, |
|
| 571 | + | scope: Scope::CHANGES | Scope::REFS, |
|
| 572 | + | expires: None, |
|
| 573 | + | }, |
|
| 574 | + | ]; |
|
| 575 | + | let d = Identity::decode(&id.encode()).unwrap(); |
|
| 576 | + | assert_eq!(d.delegates.len(), 2); |
|
| 577 | + | assert!(d.delegates_to(&agent, Scope::CHANGES, 1_000)); |
|
| 578 | + | assert!(!d.delegates_to(&agent, Scope::CHANGES, 2_001), "expired"); |
|
| 579 | + | assert!(!d.delegates_to(&agent, Scope::REFS, 1_000)); |
|
| 580 | + | assert!(!d.delegates_to(&agent, Scope::SEAL, 1_000)); |
|
| 581 | + | assert!(d.delegates_to(&host, Scope::REFS, u64::MAX)); |
|
| 582 | + | assert!(!d.delegates_to(&host, Scope::SEAL, 0)); |
|
| 583 | + | assert!(!d.delegates_to(&ObjectRef([1; 32]), Scope::CHANGES, 0)); |
|
| 584 | + | assert_eq!( |
|
| 585 | + | Scope::parse("changes, refs").unwrap(), |
|
| 586 | + | Scope::CHANGES | Scope::REFS |
|
| 587 | + | ); |
|
| 588 | + | assert_eq!(Scope::parse("all").unwrap(), Scope::ALL); |
|
| 589 | + | assert_eq!((Scope::CHANGES | Scope::SEAL).to_string(), "changes,seal"); |
|
| 590 | + | assert!(Scope::parse("push").is_err() && Scope::parse("").is_err()); |
|
| 591 | + | // An identity written before scopes existed lists bare KeyIds. |
|
| 592 | + | let mut v = id.to_value(); |
|
| 593 | + | if let Value::Map(m) = &mut v { |
|
| 594 | + | m.insert(8, Value::Array(vec![Value::Bytes(host.0.to_vec())])); |
|
| 595 | + | } |
|
| 596 | + | let old = Identity::decode(&cbor::encode(&v)).unwrap(); |
|
| 597 | + | assert_eq!(old.delegates, vec![Delegate::full(host)]); |
|
| 598 | + | assert!(old.delegates_to(&host, Scope::ALL, u64::MAX)); |
|
| 398 | 599 | } |
|
| 399 | 600 | ||
| 400 | 601 | #[test] |
|
| … | 150 unchanged lines |
modified crates/oxy-core/src/repo/hosted.rs +116 −1
| … | 249 unchanged lines |
||
| 250 | 250 | user.policy_init().unwrap(); |
|
| 251 | 251 | } |
|
| 252 | 252 | if delegate { |
|
| 253 | - | user.delegate_add(hub_id.clone()).unwrap(); |
|
| 253 | + | user.delegate_add(hub_id.clone(), Scope::CHANGES | Scope::REFS, None) |
|
| 254 | + | .unwrap(); |
|
| 254 | 255 | } |
|
| 255 | 256 | write(&work, "a.txt", b"one\ntwo\n"); |
|
| 256 | 257 | user.snapshot("t").unwrap(); |
|
| … | 212 unchanged lines |
||
| 469 | 470 | let update = host.host_ref_update("main", &[hash], None).unwrap(); |
|
| 470 | 471 | let report = host.apply_hosted(None, &[], update).unwrap(); |
|
| 471 | 472 | assert!(report.results.iter().all(|r| !r.ok)); |
|
| 473 | + | } |
|
| 474 | + | ||
| 475 | + | /// Agent doc §2: an agent's key signs changes for its owner and nothing |
|
| 476 | + | /// more. Its reference attestation is refused even though its owner's |
|
| 477 | + | /// would pass, and a seal it makes authenticates nothing. |
|
| 478 | + | #[test] |
|
| 479 | + | fn a_changes_only_delegate_signs_changes_but_cannot_seal_or_move_a_reference() { |
|
| 480 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 481 | + | let (host, mut user, _, _, _) = setup(dir.path(), true, false); |
|
| 482 | + | let agent_sk = SecretKey::generate(); |
|
| 483 | + | let agent_id = agent_sk.identity("aider on a laptop", "agent@example", 1); |
|
| 484 | + | let agent = agent_id.hash(); |
|
| 485 | + | user.delegate_add(agent_id.clone(), Scope::CHANGES, None) |
|
| 486 | + | .unwrap(); |
|
| 487 | + | // The successor identity reaches the host with the rotated policy. |
|
| 488 | + | assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok)); |
|
| 489 | + | let author = user.config.author_key.unwrap(); |
|
| 490 | + | let ids = vec![agent_id.clone(), user.identity(&author).unwrap()]; |
|
| 491 | + | let heads = host.bookmark_heads().unwrap(); |
|
| 492 | + | let change = host |
|
| 493 | + | .build_change( |
|
| 494 | + | &heads, |
|
| 495 | + | &[FileEdit { |
|
| 496 | + | path: "a.txt".into(), |
|
| 497 | + | content: Some(b"by the agent\n".to_vec()), |
|
| 498 | + | }], |
|
| 499 | + | "Agent edit", |
|
| 500 | + | author, |
|
| 501 | + | ) |
|
| 502 | + | .unwrap(); |
|
| 503 | + | let hash = change.hash(); |
|
| 504 | + | let sig = agent_sk.sign(Domain::Change, &agent, &hash); |
|
| 505 | + | // Attested by the agent: refused, the reference stays. |
|
| 506 | + | let update = host |
|
| 507 | + | .host_ref_update("main", &[hash], Some((&agent, &agent_sk))) |
|
| 508 | + | .unwrap(); |
|
| 509 | + | let report = host |
|
| 510 | + | .apply_hosted(Some((change.clone(), vec![sig.clone()])), &ids, update) |
|
| 511 | + | .unwrap(); |
|
| 512 | + | assert!(report.results.iter().all(|r| !r.ok), "{:?}", report.results); |
|
| 513 | + | assert_eq!(host.bookmark_heads().unwrap(), heads); |
|
| 514 | + | // Attested by the owner: the agent-signed change lands. |
|
| 515 | + | let (owner, owner_sk) = user.secret_key().unwrap().unwrap(); |
|
| 516 | + | let update = host |
|
| 517 | + | .host_ref_update("main", &[hash], Some((&owner, &owner_sk))) |
|
| 518 | + | .unwrap(); |
|
| 519 | + | let report = host |
|
| 520 | + | .apply_hosted(Some((change, vec![sig])), &ids, update) |
|
| 521 | + | .unwrap(); |
|
| 522 | + | assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results); |
|
| 523 | + | assert_eq!(host.bookmark_heads().unwrap(), vec![hash]); |
|
| 524 | + | assert!(matches!( |
|
| 525 | + | host.authenticate_change(&hash).unwrap(), |
|
| 526 | + | SignatureStatus::Valid { key, sealed: false, .. } if key == agent |
|
| 527 | + | )); |
|
| 528 | + | // A seal by the agent is outside its scope. |
|
| 529 | + | let change2 = host |
|
| 530 | + | .build_change( |
|
| 531 | + | &[hash], |
|
| 532 | + | &[FileEdit { |
|
| 533 | + | path: "a.txt".into(), |
|
| 534 | + | content: Some(b"sealed by the agent\n".to_vec()), |
|
| 535 | + | }], |
|
| 536 | + | "Sealed", |
|
| 537 | + | author, |
|
| 538 | + | ) |
|
| 539 | + | .unwrap(); |
|
| 540 | + | let h2 = host.store().put_change(&change2).unwrap(); |
|
| 541 | + | let seal = host |
|
| 542 | + | .store() |
|
| 543 | + | .put_attest(&Attest::seal(host.id().unwrap(), vec![h2])) |
|
| 544 | + | .unwrap(); |
|
| 545 | + | host.store() |
|
| 546 | + | .put_signature(&seal, &agent_sk.sign(Domain::Attest, &agent, &seal)) |
|
| 547 | + | .unwrap(); |
|
| 548 | + | host.store().index_seal(&h2, &seal).unwrap(); |
|
| 549 | + | assert!(matches!( |
|
| 550 | + | host.authenticate_change(&h2).unwrap(), |
|
| 551 | + | SignatureStatus::Invalid(e) if e.contains("may not seal") |
|
| 552 | + | )); |
|
| 553 | + | } |
|
| 554 | + | ||
| 555 | + | /// A delegation past its expiry authenticates nothing made after it, |
|
| 556 | + | /// and the delegator may revoke the key; a stranger's key, they may not. |
|
| 557 | + | #[test] |
|
| 558 | + | fn an_expired_delegation_is_refused_and_the_delegator_may_revoke() { |
|
| 559 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 560 | + | let (_, mut user, _, _, _) = setup(dir.path(), false, false); |
|
| 561 | + | let agent_sk = SecretKey::generate(); |
|
| 562 | + | let agent_id = agent_sk.identity("agent", "agent@example", 1); |
|
| 563 | + | let agent = agent_id.hash(); |
|
| 564 | + | let yesterday = Hlc::now().wall_ms - 24 * 3600 * 1000; |
|
| 565 | + | user.delegate_add(agent_id, Scope::CHANGES, Some(yesterday)) |
|
| 566 | + | .unwrap(); |
|
| 567 | + | user.config.set("core.sign", "seal").unwrap(); |
|
| 568 | + | user.save_config().unwrap(); |
|
| 569 | + | write(&dir.path().join("work"), "a.txt", b"later\n"); |
|
| 570 | + | user.snapshot("t").unwrap(); |
|
| 571 | + | let h = user.commit("after the delegation ended", &[]).unwrap(); |
|
| 572 | + | user.store() |
|
| 573 | + | .put_signature(&h, &agent_sk.sign(Domain::Change, &agent, &h)) |
|
| 574 | + | .unwrap(); |
|
| 575 | + | let status = user.verify_change(&h).unwrap(); |
|
| 576 | + | assert!( |
|
| 577 | + | matches!(&status, SignatureStatus::Invalid(e) if e.contains("expired")), |
|
| 578 | + | "{status:?}" |
|
| 579 | + | ); |
|
| 580 | + | // No policy here, so the user is no root: only delegation lets them revoke. |
|
| 581 | + | let stranger = SecretKey::generate().identity("x", "x@example", 1).hash(); |
|
| 582 | + | assert!(user.revoke_identity(&stranger, "").is_err()); |
|
| 583 | + | assert!(user.revoke_identity(&agent, "session over").is_ok()); |
|
| 584 | + | assert!(user.is_revoked(&agent).unwrap()); |
|
| 585 | + | let r = user.delegate_remove(&agent).unwrap(); |
|
| 586 | + | assert!(user.identity(&r.new).unwrap().delegates.is_empty()); |
|
| 472 | 587 | } |
|
| 473 | 588 | ||
| 474 | 589 | #[test] |
|
| … | 93 unchanged lines |
modified crates/oxy-core/src/repo/identity.rs +71 −25
| … | 170 unchanged lines |
||
| 171 | 171 | self.replace_identity(id, sk, "oxy identity renew") |
|
| 172 | 172 | } |
|
| 173 | 173 | ||
| 174 | - | /// Let `delegate` sign or seal on this identity's behalf. Delegates are |
|
| 175 | - | /// part of the identity object, so this re-issues it with the same keys. |
|
| 176 | - | pub fn delegate_add(&mut self, delegate: Identity) -> Result<Rotation> { |
|
| 174 | + | /// Let `delegate` act on this identity's behalf within `scope` until |
|
| 175 | + | /// `expires` (spec §14.2). Delegates are part of the identity object, |
|
| 176 | + | /// so this re-issues it with the same keys. |
|
| 177 | + | pub fn delegate_add( |
|
| 178 | + | &mut self, |
|
| 179 | + | delegate: Identity, |
|
| 180 | + | scope: Scope, |
|
| 181 | + | expires: Option<u64>, |
|
| 182 | + | ) -> Result<Rotation> { |
|
| 177 | 183 | let Some((old, sk)) = self.secret_key()? else { |
|
| 178 | 184 | return fail("no secret key: run `oxy identity create` first"); |
|
| 179 | 185 | }; |
|
| 186 | + | if scope.is_empty() { |
|
| 187 | + | return fail("a delegate needs at least one scope: changes, refs or seal"); |
|
| 188 | + | } |
|
| 180 | 189 | let dk = self.store.put_identity(&delegate)?; |
|
| 181 | 190 | if dk == old { |
|
| 182 | 191 | return fail("an identity cannot delegate to itself"); |
|
| 183 | 192 | } |
|
| 184 | 193 | let cur = self.identity(&old)?; |
|
| 185 | - | if cur.delegates.contains(&dk) { |
|
| 186 | - | return fail(format!("{} is already a delegate", dk.short(12))); |
|
| 194 | + | if cur.delegate(&dk).is_some() { |
|
| 195 | + | return fail(format!( |
|
| 196 | + | "{} is already a delegate; remove it first to change its scope or expiry", |
|
| 197 | + | dk.short(12) |
|
| 198 | + | )); |
|
| 187 | 199 | } |
|
| 188 | 200 | let mut delegates = cur.delegates.clone(); |
|
| 189 | - | delegates.push(dk); |
|
| 201 | + | delegates.push(Delegate { |
|
| 202 | + | key: dk, |
|
| 203 | + | scope, |
|
| 204 | + | expires, |
|
| 205 | + | }); |
|
| 190 | 206 | let id = cur.successor(delegates, cur.expires, Hlc::now().wall_ms); |
|
| 191 | 207 | self.replace_identity( |
|
| 192 | 208 | id, |
|
| 193 | 209 | sk, |
|
| 194 | - | &format!("oxy identity delegate add {}", dk.short(12)), |
|
| 210 | + | &format!("oxy identity delegate add {} --scope {scope}", dk.short(12)), |
|
| 195 | 211 | ) |
|
| 196 | 212 | } |
|
| 197 | 213 | ||
| … | 3 unchanged lines |
||
| 201 | 217 | return fail("no secret key: run `oxy identity create` first"); |
|
| 202 | 218 | }; |
|
| 203 | 219 | let cur = self.identity(&old)?; |
|
| 204 | - | if !cur.delegates.contains(delegate) { |
|
| 220 | + | if cur.delegate(delegate).is_none() { |
|
| 205 | 221 | return fail(format!("{} is not a delegate", delegate.short(12))); |
|
| 206 | 222 | } |
|
| 207 | 223 | let delegates = cur |
|
| 208 | 224 | .delegates |
|
| 209 | 225 | .iter() |
|
| 210 | 226 | .copied() |
|
| 211 | - | .filter(|d| d != delegate) |
|
| 227 | + | .filter(|d| d.key != *delegate) |
|
| 212 | 228 | .collect(); |
|
| 213 | 229 | let id = cur.successor(delegates, cur.expires, Hlc::now().wall_ms); |
|
| 214 | 230 | self.replace_identity( |
|
| … | 3 unchanged lines |
||
| 218 | 234 | ) |
|
| 219 | 235 | } |
|
| 220 | 236 | ||
| 221 | - | /// Revoke `key` (your own, or any key when you hold a policy root): |
|
| 222 | - | /// record a signed `revoke` attestation and a local marker, and drop |
|
| 223 | - | /// the key from the policy where it has one. |
|
| 237 | + | /// Revoke `key` (your own, one you delegated to, or any key when you |
|
| 238 | + | /// hold a policy root): record a signed `revoke` attestation and a |
|
| 239 | + | /// local marker, and drop the key from the policy where it has one. |
|
| 240 | + | /// Revoking a delegate does not re-issue the identity; `delegate_remove` |
|
| 241 | + | /// does that, and the CLI does both. |
|
| 224 | 242 | pub fn revoke_identity(&self, key: &KeyId, reason: &str) -> Result<Option<PolicyUpdate>> { |
|
| 225 | 243 | let Some((own, sk)) = self.secret_key()? else { |
|
| 226 | 244 | return fail("no secret key: run `oxy identity create` first"); |
|
| 227 | 245 | }; |
|
| 228 | 246 | let policy = self.current_policy()?.map(|(_, sp)| sp.policy); |
|
| 229 | 247 | let is_root = policy.as_ref().is_some_and(|p| p.roots.contains(&own)); |
|
| 230 | - | if *key != own && !is_root { |
|
| 231 | - | return fail("only the key's holder or a policy root can revoke it"); |
|
| 248 | + | let is_delegate = self.identity(&own)?.delegate(key).is_some(); |
|
| 249 | + | if *key != own && !is_root && !is_delegate { |
|
| 250 | + | return fail("only the key's holder, the identity that delegated to it, or a policy root can revoke it"); |
|
| 232 | 251 | } |
|
| 233 | 252 | let attest = Attest { |
|
| 234 | 253 | repo: self.id()?, |
|
| … | 134 unchanged lines |
||
| 369 | 388 | // The author-or-delegate rule (spec §14.4). A change whose author is |
|
| 370 | 389 | // the zero key names nobody (Git imports, history from before an |
|
| 371 | 390 | // identity existed), so any key may adopt it with a seal; policy |
|
| 372 | - | // then authorises that key for the reference as usual. |
|
| 373 | - | let authorised = change.author.is_zero() |
|
| 374 | - | || sig.key == change.author |
|
| 375 | - | || self |
|
| 391 | + | // then authorises that key for the reference as usual. A delegate |
|
| 392 | + | // acts within its scope, signing changes or sealing them, and only |
|
| 393 | + | // until its delegation expires (§14.2). |
|
| 394 | + | if !change.author.is_zero() && sig.key != change.author { |
|
| 395 | + | let needed = match domain { |
|
| 396 | + | Domain::Change => Scope::CHANGES, |
|
| 397 | + | _ => Scope::SEAL, |
|
| 398 | + | }; |
|
| 399 | + | let delegation = self |
|
| 376 | 400 | .store |
|
| 377 | 401 | .get_identity(&change.author) |
|
| 378 | - | .map(|a| a.delegates.contains(&sig.key)) |
|
| 379 | - | .unwrap_or(false); |
|
| 380 | - | if !authorised { |
|
| 381 | - | return Err(format!( |
|
| 382 | - | "key {} is neither the author nor a delegate", |
|
| 383 | - | sig.key.short(12) |
|
| 384 | - | )); |
|
| 402 | + | .ok() |
|
| 403 | + | .and_then(|a| a.delegate(&sig.key).copied()); |
|
| 404 | + | match delegation { |
|
| 405 | + | None => { |
|
| 406 | + | return Err(format!( |
|
| 407 | + | "key {} is neither the author nor a delegate", |
|
| 408 | + | sig.key.short(12) |
|
| 409 | + | )) |
|
| 410 | + | } |
|
| 411 | + | Some(d) if !d.scope.has(needed) => { |
|
| 412 | + | return Err(format!( |
|
| 413 | + | "key {} is a delegate for {} only and may not {}", |
|
| 414 | + | sig.key.short(12), |
|
| 415 | + | d.scope, |
|
| 416 | + | if needed == Scope::SEAL { |
|
| 417 | + | "seal" |
|
| 418 | + | } else { |
|
| 419 | + | "sign changes" |
|
| 420 | + | } |
|
| 421 | + | )) |
|
| 422 | + | } |
|
| 423 | + | Some(d) if !d.allows(needed, change.hlc.wall_ms) => { |
|
| 424 | + | return Err(format!( |
|
| 425 | + | "key {}'s delegation had expired when the change was made", |
|
| 426 | + | sig.key.short(12) |
|
| 427 | + | )) |
|
| 428 | + | } |
|
| 429 | + | Some(_) => {} |
|
| 430 | + | } |
|
| 385 | 431 | } |
|
| 386 | 432 | if id.is_expired_at(change.hlc.wall_ms) { |
|
| 387 | 433 | return Err(format!( |
|
| … | 121 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +3 −1
| … | 9 unchanged lines |
||
| 10 | 10 | use crate::attest::{Attest, AttestBody}; |
|
| 11 | 11 | use crate::diff::{ranges, split_lines, text_edits}; |
|
| 12 | 12 | use crate::hash::{hash_object, ChangeHash, KeyId, ObjectRef, ObjectType}; |
|
| 13 | - | use crate::identity::{verify as verify_signature, Domain, Identity, SecretKey, Signature}; |
|
| 13 | + | use crate::identity::{ |
|
| 14 | + | verify as verify_signature, Delegate, Domain, Identity, Scope, SecretKey, Signature, |
|
| 15 | + | }; |
|
| 14 | 16 | use crate::ids::{ChangeId, NodeId, WorkspaceId}; |
|
| 15 | 17 | use crate::objects::{ |
|
| 16 | 18 | Blob, Change, ChangeKind, Edit, Hlc, Mode, NodeKind, OpRef, StateObject, TreeOp, |
|
| … | 1558 unchanged lines |
modified crates/oxy-core/src/repo/sync.rs +13 −6
| … | 414 unchanged lines |
||
| 415 | 415 | /// server receiving a push requires a strictly greater serial. |
|
| 416 | 416 | /// A reference attestation signed by a delegate of an identity the |
|
| 417 | 417 | /// rule lists is authorized as that identity (spec §14.5): the identity |
|
| 418 | - | /// named the delegate, and the policy names the identity. Revoked keys |
|
| 419 | - | /// authorize nothing either way. |
|
| 418 | + | /// named the delegate with the `refs` scope, still in force at `at_ms` |
|
| 419 | + | /// (the attestation's wall time), and the policy names the identity. |
|
| 420 | + | /// Revoked keys authorize nothing either way. |
|
| 420 | 421 | pub(crate) fn delegate_authorized( |
|
| 421 | 422 | &self, |
|
| 422 | 423 | p: &crate::policy::Policy, |
|
| 423 | 424 | signer: &KeyId, |
|
| 424 | 425 | reference: &str, |
|
| 426 | + | at_ms: u64, |
|
| 425 | 427 | ) -> bool { |
|
| 426 | 428 | if p.is_revoked(signer) { |
|
| 427 | 429 | return false; |
|
| … | 6 unchanged lines |
||
| 434 | 436 | && p.identity(k) |
|
| 435 | 437 | .cloned() |
|
| 436 | 438 | .or_else(|| self.store.get_identity(k).ok()) |
|
| 437 | - | .is_some_and(|id| id.delegates.contains(signer)) |
|
| 439 | + | .is_some_and(|id| id.delegates_to(signer, Scope::REFS, at_ms)) |
|
| 438 | 440 | }) |
|
| 439 | 441 | } |
|
| 440 | 442 | ||
| … | 199 unchanged lines |
||
| 640 | 642 | .first() |
|
| 641 | 643 | .map(|s| s.key) |
|
| 642 | 644 | .ok_or("attestation carries no signature")?; |
|
| 643 | - | if !p.authorizes(&signer, &u.name) && !self.delegate_authorized(p, &signer, &u.name) { |
|
| 645 | + | if !p.authorizes(&signer, &u.name) |
|
| 646 | + | && !self.delegate_authorized(p, &signer, &u.name, sa.attest.hlc.wall_ms) |
|
| 647 | + | { |
|
| 644 | 648 | return Err(format!( |
|
| 645 | 649 | "key {} may not delete {:?} under this repository's policy", |
|
| 646 | 650 | signer.short(12), |
|
| … | 85 unchanged lines |
||
| 732 | 736 | .first() |
|
| 733 | 737 | .map(|s| s.key) |
|
| 734 | 738 | .ok_or("attestation carries no signature")?; |
|
| 735 | - | if !p.authorizes(&signer, &u.name) && !self.delegate_authorized(p, &signer, &u.name) { |
|
| 739 | + | if !p.authorizes(&signer, &u.name) |
|
| 740 | + | && !self.delegate_authorized(p, &signer, &u.name, sa.attest.hlc.wall_ms) |
|
| 741 | + | { |
|
| 736 | 742 | return Err(format!( |
|
| 737 | 743 | "key {} may not update {:?} under this repository's policy", |
|
| 738 | 744 | signer.short(12), |
|
| … | 350 unchanged lines |
||
| 1089 | 1095 | .first() |
|
| 1090 | 1096 | .map(|s| s.key) |
|
| 1091 | 1097 | .ok_or_else(|| RepoError::Msg("attestation carries no signature".into()))?; |
|
| 1092 | - | if !p.authorizes(&signer, &r.name) && !self.delegate_authorized(p, &signer, &r.name) |
|
| 1098 | + | if !p.authorizes(&signer, &r.name) |
|
| 1099 | + | && !self.delegate_authorized(p, &signer, &r.name, sa.attest.hlc.wall_ms) |
|
| 1093 | 1100 | { |
|
| 1094 | 1101 | return fail(format!( |
|
| 1095 | 1102 | "{remote_name}/{}: attested by {}, which the policy does not authorize for this bookmark; a policy owner can grant it with `oxy policy grant`", |
|
| … | 550 unchanged lines |
modified crates/oxy-core/src/repo/tags.rs +1 −1
| … | 211 unchanged lines |
||
| 212 | 212 | if let Some(p) = policy { |
|
| 213 | 213 | let reference = format!("tags/{}", t.name); |
|
| 214 | 214 | if !p.authorizes(&sig.key, &reference) |
|
| 215 | - | && !self.delegate_authorized(p, &sig.key, &reference) |
|
| 215 | + | && !self.delegate_authorized(p, &sig.key, &reference, sa.attest.hlc.wall_ms) |
|
| 216 | 216 | { |
|
| 217 | 217 | return Err(format!( |
|
| 218 | 218 | "key {} may not create tags/{} under this repository's policy", |
|
| … | 26 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +9 −3
| … | 2483 unchanged lines |
||
| 2484 | 2484 | let (key_b, id_b) = b |
|
| 2485 | 2485 | .create_identity("Bob", "[email protected]", IdentityScope::User) |
|
| 2486 | 2486 | .unwrap(); |
|
| 2487 | - | let r = a.delegate_add(id_b.clone()).unwrap(); |
|
| 2487 | + | let r = a.delegate_add(id_b.clone(), Scope::ALL, None).unwrap(); |
|
| 2488 | 2488 | assert_eq!(r.old, key_a); |
|
| 2489 | 2489 | let key_a2 = r.new; |
|
| 2490 | - | assert_eq!(a.identity(&key_a2).unwrap().delegates, vec![key_b]); |
|
| 2491 | 2490 | assert_eq!( |
|
| 2491 | + | a.identity(&key_a2).unwrap().delegates, |
|
| 2492 | + | vec![Delegate::full(key_b)] |
|
| 2493 | + | ); |
|
| 2494 | + | assert_eq!( |
|
| 2492 | 2495 | a.identity(&key_a2).unwrap().ed25519_pk, |
|
| 2493 | 2496 | a.identity(&key_a).unwrap().ed25519_pk |
|
| 2497 | + | ); |
|
| 2498 | + | assert!( |
|
| 2499 | + | a.delegate_add(id_b.clone(), Scope::ALL, None).is_err(), |
|
| 2500 | + | "already a delegate" |
|
| 2494 | 2501 | ); |
|
| 2495 | - | assert!(a.delegate_add(id_b.clone()).is_err(), "already a delegate"); |
|
| 2496 | 2502 | assert!(a.push("origin", false).unwrap().iter().all(|x| x.ok)); |
|
| 2497 | 2503 | write(&root.join("a"), "a.txt", b"a3\n"); |
|
| 2498 | 2504 | let h3 = a.commit("three", &[]).unwrap(); |
|
| … | 2552 unchanged lines |
modified docs/ai_native_agent_workflows.md +9 −9
| … | 46 unchanged lines |
||
| 47 | 47 | [9]` is reserved and must stay empty, and the signature already proves |
|
| 48 | 48 | which agent made the change, which a marker could not. |
|
| 49 | 49 | ||
| 50 | - | **New: scoped delegation.** Today a delegate moves references as far as |
|
| 51 | - | its delegator may (§14.5). That is right for a hub signing browser edits, |
|
| 52 | - | and wrong for an agent. `delegates` becomes a list of entries: |
|
| 50 | + | **Scoped delegation (§14.2, implemented 2026-10-04).** Before it, a |
|
| 51 | + | delegate moved references as far as its delegator may (§14.5). That is |
|
| 52 | + | right for a hub signing browser edits, and wrong for an agent. `delegates` |
|
| 53 | + | is a list of entries: |
|
| 53 | 54 | ||
| 54 | 55 | ``` |
|
| 55 | 56 | delegates [8]: [{ key [0]: KeyId, scope [1]: ["changes" | "refs" | "seal"], expires [2]: u64? }] |
|
| … | 7 unchanged lines |
||
| 63 | 64 | (`--expires 8h`), without rotating the human's own expiry. |
|
| 64 | 65 | - Hosts that sign for users keep the current behaviour with |
|
| 65 | 66 | `--scope changes,refs`. |
|
| 66 | - | - This is a format change to the identity object. That is acceptable |
|
| 67 | - | before 1.0. |
|
| 67 | + | - A bare KeyId in an older identity means every scope. |
|
| 68 | 68 | ||
| 69 | - | **New: revocation by the delegator.** Today only the revoked key itself |
|
| 70 | - | or a policy root key may sign `revoke`. An agent's key may be lost on a |
|
| 69 | + | **Revocation by the delegator (implemented 2026-10-04).** Before it, |
|
| 70 | + | only the revoked key itself or a policy root key could sign `revoke`. An agent's key may be lost on a |
|
| 71 | 71 | compromised machine, and the policy root is often someone else, so the |
|
| 72 | 72 | identity that delegated a key may also revoke it. Removing a delegate in |
|
| 73 | 73 | a successor identity stops future use. Revoking it also marks the changes |
|
| … | 144 unchanged lines |
||
| 218 | 218 | ||
| 219 | 219 | | Item | Spec section to change | |
|
| 220 | 220 | |---|---| |
|
| 221 | - | | Scoped, expiring delegates | §14.2 identity object, §14.5 policy | |
|
| 222 | - | | Revocation by the delegator | §14.2 | |
|
| 221 | + | | Scoped, expiring delegates (done 2026-10-04) | §14.2 identity object, §14.4, §14.5 policy | |
|
| 222 | + | | Revocation by the delegator (done 2026-10-04) | §14.2 | |
|
| 223 | 223 | | `oxy agent start` / `drop`, session operation | §12, CLI | |
|
| 224 | 224 | | `oxy undo --to OP` | §12, CLI | |
|
| 225 | 225 | | Display folding, `oxy fold -r` | CLI, hub | |
|
| … | 3 unchanged lines |
modified docs/guide.md +3 −1
| … | 261 unchanged lines |
||
| 262 | 262 | ``` |
|
| 263 | 263 | oxy identity rotate # fresh key pair; old signatures stay valid |
|
| 264 | 264 | oxy identity renew --expires 365 # same keys, new expiry in days (0 = never) |
|
| 265 | - | oxy identity delegate add ci.identity # let another key sign on your behalf |
|
| 265 | + | oxy identity delegate add agent.identity --expires 8h # sign changes for me today |
|
| 266 | + | oxy identity delegate add ci.identity --scope changes,refs # sign and push for me |
|
| 266 | 267 | oxy identity delegate remove 3f2a… |
|
| 268 | + | oxy identity revoke 3f2a… --reason "agent host compromised" # a key you delegated to |
|
| 267 | 269 | oxy identity revoke # retire your own key |
|
| 268 | 270 | oxy identity revoke 3f2a… --reason "laptop stolen" # any key, if you hold a root |
|
| 269 | 271 | ``` |
|
| … | 173 unchanged lines |
modified docs/spec.md +9 −4
| … | 586 unchanged lines |
||
| 587 | 587 | created [5]: u64 (wall ms) |
|
| 588 | 588 | expires [6]: u64 | null |
|
| 589 | 589 | previous [7]: KeyId | null // rotation chain |
|
| 590 | - | delegates [8]: [KeyId] // keys allowed to sign or seal on this identity's behalf |
|
| 590 | + | delegates [8]: [Delegate] // keys allowed to act on this identity's behalf |
|
| 591 | 591 | } |
|
| 592 | + | Delegate { |
|
| 593 | + | key [0]: KeyId |
|
| 594 | + | scope [1]: ["changes" | "refs" | "seal"] // what it may do, in that order |
|
| 595 | + | expires [2]: u64 | null // wall ms; null for as long as it is listed |
|
| 596 | + | } |
|
| 592 | 597 | KeyId = ObjectRef of the Identity |
|
| 593 | 598 | ``` |
|
| 594 | 599 | ||
| 595 | - | The two public keys live in one object, so a `KeyId` names the pair. `delegates` lists keys that may authenticate changes whose `author` is this identity: a proxy acting for a Thin client (§22), or a CI signer. Delegation is one level deep and is not transitive. Rotation is a new identity with `previous` set, plus an attestation of kind `rotate` signed by the previous key. Revocation is an attestation of kind `revoke` signed by the revoked key or by a policy root key (§14.5). |
|
| 600 | + | The two public keys live in one object, so a `KeyId` names the pair. `delegates` lists keys that may act for this identity, each within a **scope**: `changes` lets the key sign changes whose `author` is this identity, `seal` lets it seal them, and `refs` lets it attest references and tags as this identity would (§14.5). A proxy acting for a Thin client (§22) or a host that commits browser edits for a user gets `changes,refs`; an AI agent gets `changes` alone, so its signature authenticates the changes it makes but it can never move a reference, whatever its owner may do. A delegation with `expires` authenticates nothing whose `hlc.wall_ms` is later than that, so an agent can be let in for a session without changing the owner's own expiry. Delegation is one level deep and is not transitive. A bare `KeyId` in `delegates`, as identities carried before scopes existed, means every scope with no expiry. Rotation is a new identity with `previous` set, plus an attestation of kind `rotate` signed by the previous key. Revocation is an attestation of kind `revoke` signed by the revoked key, by the identity that delegated to it, or by a policy root key (§14.5); the delegator's revocation marks the changes the key already signed as valid but revoked, and the delegator then re-issues their identity without it. |
|
| 596 | 601 | ||
| 597 | 602 | **Lifecycle rules.** Because the `KeyId` is the hash of the whole object, any change to an identity, including its `delegates` or `expires`, is a rotation: a successor object with `previous` set. A successor MAY keep the same key pair (`oxy identity renew`, `oxy identity delegate add|remove`) or carry a fresh one (`oxy identity rotate`); either way earlier signatures remain verifiable under the earlier object, which stays stored. A rotation **supersedes** the previous KeyId: a policy that named it MUST be updated to name the successor (§14.5), and a rule key covers every predecessor in its `previous` chain that is not revoked, so changes signed before a rotation remain authorized. Revocation is for compromised or abandoned keys: a revoked key authorizes nothing, whatever its chain. `expires` bounds the changes a key can authenticate: a signature or seal over a change whose `hlc.wall_ms` is later than the identity's `expires` is invalid. A client records the revocations it makes or verifies under `.oxy/revoked/` and reports signatures by such keys as valid but revoked. |
|
| 598 | 603 | ||
| … | 38 unchanged lines |
||
| 637 | 642 | ||
| 638 | 643 | **Cadence.** With the default `core.sign = "seal"`, changes are left unsigned locally and `oxy push` creates one seal covering every change in the push that carries no individual signature, immediately before the reference attestation. Seals are created only at push: a seal made earlier would be invalidated by any amend, since rewriting produces a new hash. With `core.sign = "change"`, every change is signed at commit and no seal is needed. Policy (§14.5) decides which the receiving repository accepts. |
|
| 639 | 644 | ||
| 640 | - | **Verification.** A sealed change `C` is authenticated iff a seal `S` exists such that `hash(C) ∈ S.body.hashes`, `S` carries a valid signature from key `K`, and `K` equals `C.author` or is listed in `C.author`'s identity as a delegate (§14.2). The same author-or-delegate rule applies to individual change signatures. This rule is what stops a collaborator from sealing a change whose `author` field names someone else (§3.1). A change whose `author` is the zero KeyId names nobody (history imported from Git, §21, or made before an identity existed), so a seal from any key authenticates it: the sealer **adopts** it, and policy authorizes the sealer for the reference as usual. A push seals the pusher's own unauthenticated changes and adopts anonymous ones in the same seal. A seal's signer must additionally be authorized by policy for the reference being pushed. |
|
| 645 | + | **Verification.** A sealed change `C` is authenticated iff a seal `S` exists such that `hash(C) ∈ S.body.hashes`, `S` carries a valid signature from key `K`, and `K` equals `C.author` or is listed in `C.author`'s identity as a delegate with the `seal` scope, not expired at `C.hlc.wall_ms` (§14.2). The same author-or-delegate rule applies to individual change signatures, with the `changes` scope. This rule is what stops a collaborator from sealing a change whose `author` field names someone else (§3.1). A change whose `author` is the zero KeyId names nobody (history imported from Git, §21, or made before an identity existed), so a seal from any key authenticates it: the sealer **adopts** it, and policy authorizes the sealer for the reference as usual. A push seals the pusher's own unauthenticated changes and adopts anonymous ones in the same seal. A seal's signer must additionally be authorized by policy for the reference being pushed. |
|
| 641 | 646 | ||
| 642 | 647 | **Handling.** A seal is stored and transported with the changes it covers: `fetch` (§16.1) streams the seals covering every change it returns, `push` (§16.4) MUST include a seal or signature for every change, and a seal covering any reachable change is itself reachable (§21). Seals from different keys MAY overlap; a change needs at least one valid authenticator, and policy thresholds count distinct authenticating keys. |
|
| 643 | 648 | ||
| … | 17 unchanged lines |
||
| 661 | 666 | ||
| 662 | 667 | - The initial policy is signed by the root keys; every update MUST be signed by `roots.threshold` root keys of the policy it replaces and have a higher `serial`. An update that a single holder cannot complete is a **proposal**: the signed policy (`[policy bytes, [Signature]]`, §16.3) is passed to other root holders, each of whom adds a signature with `oxy policy sign`; whoever completes the threshold applies it. Rotating a root key (§14.2) substitutes the successor in the roots and rules; such a policy is signed by both the previous and the successor key so a fresh clone can bootstrap from it. |
|
| 663 | 668 | - A change's signer is authorized by a rule if the rule lists it, or lists a successor of it, or lists the change's `author` (or a successor) when the signer is that author's delegate. |
|
| 664 | - | - A reference attestation's signer is authorized by a rule if the rule lists it, or lists an identity whose `delegates` name it (§14.2). Delegation is the identity's own signed statement and the policy names that identity, so a delegate moves references exactly as far as its delegator may; revoking either key ends it. Hosts use this to land and to commit browser edits on a user's behalf with their own key. |
|
| 669 | + | - A reference attestation's signer is authorized by a rule if the rule lists it, or lists an identity whose `delegates` name it with the `refs` scope, not expired at the attestation's `hlc.wall_ms` (§14.2). Delegation is the identity's own signed statement and the policy names that identity, so a delegate with `refs` moves references exactly as far as its delegator may; revoking either key, or the delegation expiring, ends it. Hosts use this to land and to commit browser edits on a user's behalf with their own key. A delegate without `refs` (an agent) is never authorized for a reference or tag, even when its delegator is. |
|
| 665 | 670 | - A change or attestation is **authorized** for reference `r` if its signer is listed in the first rule whose pattern matches `r`, and the rule's threshold of distinct authorized signers is met across the attestation and each change in the closure of `new_state` not previously in the closure of `old_state`. `require` selects which authenticators count for changes: individual signatures only, seals only, or either. Every change MUST additionally satisfy the author-or-delegate rule of §14.4 regardless of `require`. |
|
| 666 | 671 | - Clients MUST verify policy on fetch, MUST refuse to update a `remotes/` reference whose attestation is unauthorized, and MUST warn loudly on an expired policy. Servers MUST enforce the same rules on push. |
|
| 667 | 672 | - A repository without a `policy` reference is **unverified**; every command that talks to a remote MUST print a one-line warning until one is created. `oxy policy init` creates one with the author's key as the sole root; `oxy policy grant` and `oxy policy revoke` produce successors. |
|
| … | 344 unchanged lines |
modified src/cli/identity.rs +80 −28
| … | 6 unchanged lines |
||
| 7 | 7 | use anyhow::Result; |
|
| 8 | 8 | use oxy_core::hash::ChangeHash; |
|
| 9 | 9 | use oxy_core::hash::KeyId; |
|
| 10 | - | use oxy_core::identity::Identity; |
|
| 10 | + | use oxy_core::identity::{Delegate, Identity, Scope}; |
|
| 11 | 11 | use oxy_core::objects::Hlc; |
|
| 12 | 12 | use oxy_core::protocol::SignedPolicy; |
|
| 13 | 13 | use oxy_core::repo::{self, IdentityScope, PolicyUpdate, Repo, Rotation, SignatureStatus}; |
|
| … | 5 unchanged lines |
||
| 19 | 19 | (days > 0).then(|| Hlc::now().wall_ms + days * DAY_MS) |
|
| 20 | 20 | } |
|
| 21 | 21 | ||
| 22 | + | /// `30m`, `8h`, `7d`, or a bare number of days, as a wall time from now. |
|
| 23 | + | fn expiry_from_duration(s: &str) -> Result<u64> { |
|
| 24 | + | let s = s.trim(); |
|
| 25 | + | let (n, unit) = match s.char_indices().find(|(_, c)| !c.is_ascii_digit()) { |
|
| 26 | + | Some((i, _)) => (&s[..i], &s[i..]), |
|
| 27 | + | None => (s, "d"), |
|
| 28 | + | }; |
|
| 29 | + | let n: u64 = n.parse().map_err(|_| { |
|
| 30 | + | anyhow::anyhow!("--expires takes 30m, 8h, 7d or a number of days, not {s:?}") |
|
| 31 | + | })?; |
|
| 32 | + | let ms = match unit { |
|
| 33 | + | "m" => 60_000, |
|
| 34 | + | "h" => 3_600_000, |
|
| 35 | + | "d" => DAY_MS, |
|
| 36 | + | _ => anyhow::bail!("--expires takes 30m, 8h, 7d or a number of days, not {s:?}"), |
|
| 37 | + | }; |
|
| 38 | + | if n == 0 { |
|
| 39 | + | anyhow::bail!("--expires needs a length of time; leave it out for no expiry"); |
|
| 40 | + | } |
|
| 41 | + | Ok(Hlc::now().wall_ms + n * ms) |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | /// One line per delegate: key, scope, expiry. |
|
| 45 | + | fn delegate_lines(delegates: &[Delegate]) -> String { |
|
| 46 | + | delegates |
|
| 47 | + | .iter() |
|
| 48 | + | .map(|d| { |
|
| 49 | + | format!( |
|
| 50 | + | "{} ({}{})", |
|
| 51 | + | d.key.short(12), |
|
| 52 | + | d.scope, |
|
| 53 | + | d.expires |
|
| 54 | + | .map(|e| format!(", until {}", when(e))) |
|
| 55 | + | .unwrap_or_default() |
|
| 56 | + | ) |
|
| 57 | + | }) |
|
| 58 | + | .collect::<Vec<_>>() |
|
| 59 | + | .join(" ") |
|
| 60 | + | } |
|
| 61 | + | ||
| 22 | 62 | /// Resolve a KeyId prefix among the identities the repository knows. |
|
| 23 | 63 | fn resolve_key(repo: &Repo, prefix: &str) -> Result<KeyId> { |
|
| 24 | 64 | if let Some(k) = KeyId::from_hex(prefix) { |
|
| … | 7 unchanged lines |
||
| 32 | 72 | if let Some(own) = repo.config.author_key { |
|
| 33 | 73 | candidates.push(own); |
|
| 34 | 74 | if let Ok(id) = repo.identity(&own) { |
|
| 35 | - | candidates.extend(id.delegates.iter().copied()); |
|
| 75 | + | candidates.extend(id.delegates.iter().map(|d| d.key)); |
|
| 36 | 76 | } |
|
| 37 | 77 | } |
|
| 38 | 78 | candidates.sort(); |
|
| … | 48 unchanged lines |
||
| 87 | 127 | if !r.identity.delegates.is_empty() { |
|
| 88 | 128 | println!( |
|
| 89 | 129 | "{}", |
|
| 90 | - | ui::kv( |
|
| 91 | - | "delegates", |
|
| 92 | - | r.identity |
|
| 93 | - | .delegates |
|
| 94 | - | .iter() |
|
| 95 | - | .map(|d| d.short(12)) |
|
| 96 | - | .collect::<Vec<_>>() |
|
| 97 | - | .join(" ") |
|
| 98 | - | ) |
|
| 130 | + | ui::kv("delegates", delegate_lines(&r.identity.delegates)) |
|
| 99 | 131 | ); |
|
| 100 | 132 | } |
|
| 101 | 133 | if let Some(e) = r.identity.expires { |
|
| … | 100 unchanged lines |
||
| 202 | 234 | println!("{}", ui::kv("previous", ui::dim(p.short(12)))); |
|
| 203 | 235 | } |
|
| 204 | 236 | if !id.delegates.is_empty() { |
|
| 205 | - | println!( |
|
| 206 | - | "{}", |
|
| 207 | - | ui::kv( |
|
| 208 | - | "delegates", |
|
| 209 | - | id.delegates |
|
| 210 | - | .iter() |
|
| 211 | - | .map(|d| d.short(12)) |
|
| 212 | - | .collect::<Vec<_>>() |
|
| 213 | - | .join(" ") |
|
| 214 | - | ) |
|
| 215 | - | ); |
|
| 237 | + | println!("{}", ui::kv("delegates", delegate_lines(&id.delegates))); |
|
| 216 | 238 | } |
|
| 217 | 239 | if repo.is_revoked(&key)? { |
|
| 218 | 240 | println!( |
|
| … | 193 unchanged lines |
||
| 412 | 434 | ui::dim("recorded locally; no policy named the key") |
|
| 413 | 435 | ), |
|
| 414 | 436 | } |
|
| 437 | + | // A revoked delegate comes off the identity too, so the successor no |
|
| 438 | + | // longer names it (spec §14.2). |
|
| 439 | + | let own = repo.config.author_key; |
|
| 440 | + | let delegated = own.is_some_and(|o| { |
|
| 441 | + | repo.identity(&o) |
|
| 442 | + | .is_ok_and(|id| id.delegate(&key).is_some()) |
|
| 443 | + | }); |
|
| 444 | + | if delegated { |
|
| 445 | + | let mut repo = repo; |
|
| 446 | + | let r = repo.delegate_remove(&key)?; |
|
| 447 | + | report_rotation(&r, "Undelegated")?; |
|
| 448 | + | return Ok(0); |
|
| 449 | + | } |
|
| 415 | 450 | if repo.config.author_key == Some(key) { |
|
| 416 | 451 | println!( |
|
| 417 | 452 | " {} {}", |
|
| … | 4 unchanged lines |
||
| 422 | 457 | Ok(0) |
|
| 423 | 458 | } |
|
| 424 | 459 | ||
| 425 | - | pub fn delegate_add(identity_file: &Path) -> Result<i32> { |
|
| 460 | + | pub fn delegate_add(identity_file: &Path, scope: &str, expires: Option<&str>) -> Result<i32> { |
|
| 426 | 461 | let mut repo = open()?; |
|
| 427 | 462 | let id = Identity::decode(&std::fs::read(identity_file)?)?; |
|
| 428 | - | let r = repo.delegate_add(id)?; |
|
| 463 | + | let scope = Scope::parse(scope).map_err(|e| anyhow::anyhow!("{e}"))?; |
|
| 464 | + | let expires = expires.map(expiry_from_duration).transpose()?; |
|
| 465 | + | let r = repo.delegate_add(id, scope, expires)?; |
|
| 429 | 466 | report_rotation(&r, "Delegated")?; |
|
| 467 | + | if !scope.has(Scope::REFS) { |
|
| 468 | + | println!( |
|
| 469 | + | " {} {}", |
|
| 470 | + | ui::note(), |
|
| 471 | + | ui::dim("it may not move references: a push or tag it attests is refused") |
|
| 472 | + | ); |
|
| 473 | + | } |
|
| 430 | 474 | Ok(0) |
|
| 431 | 475 | } |
|
| 432 | 476 | ||
| … | 15 unchanged lines |
||
| 448 | 492 | if id.delegates.is_empty() { |
|
| 449 | 493 | println!("{}", ui::dim("No delegates.")); |
|
| 450 | 494 | } |
|
| 495 | + | let now = Hlc::now().wall_ms; |
|
| 451 | 496 | for d in &id.delegates { |
|
| 452 | 497 | let who = repo |
|
| 453 | - | .identity(d) |
|
| 498 | + | .identity(&d.key) |
|
| 454 | 499 | .map(|i| format!("{} <{}>", i.name, i.email)) |
|
| 455 | 500 | .unwrap_or_else(|_| "(unknown identity)".into()); |
|
| 501 | + | let until = match d.expires { |
|
| 502 | + | Some(e) if now > e => ui::err(format!("expired {}", ui::ago(e))), |
|
| 503 | + | Some(e) => ui::dim(format!("until {}", when(e))), |
|
| 504 | + | None => String::new(), |
|
| 505 | + | }; |
|
| 456 | 506 | println!( |
|
| 457 | - | " {} {} {}", |
|
| 507 | + | " {} {} {} {} {}", |
|
| 458 | 508 | ui::ok(ui::glyph("🔑", "*")), |
|
| 459 | - | ui::hash(d.short(12)), |
|
| 460 | - | ui::author(who) |
|
| 509 | + | ui::hash(d.key.short(12)), |
|
| 510 | + | ui::author(who), |
|
| 511 | + | ui::dim(d.scope.to_string()), |
|
| 512 | + | until |
|
| 461 | 513 | ); |
|
| 462 | 514 | } |
|
| 463 | 515 | Ok(0) |
|
| … | 309 unchanged lines |
modified src/cli/mod.rs +12 −1
| … | 680 unchanged lines |
||
| 681 | 681 | Add { |
|
| 682 | 682 | /// The delegate's exported identity file |
|
| 683 | 683 | identity_file: PathBuf, |
|
| 684 | + | /// What it may do for you: changes, refs, seal (comma-separated) or all. |
|
| 685 | + | /// An agent gets `changes`; a host that lands for you needs `changes,refs` |
|
| 686 | + | #[arg(long, default_value = "changes")] |
|
| 687 | + | scope: String, |
|
| 688 | + | /// How long for: 30m, 8h, 7d, or a number of days (default: until removed) |
|
| 689 | + | #[arg(long)] |
|
| 690 | + | expires: Option<String>, |
|
| 684 | 691 | }, |
|
| 685 | 692 | /// Remove a delegate by KeyId prefix |
|
| 686 | 693 | Remove { |
|
| … | 297 unchanged lines |
||
| 984 | 991 | IdentityCmd::Renew { expires } => identity::renew(expires), |
|
| 985 | 992 | IdentityCmd::Revoke { key, reason } => identity::revoke(key.as_deref(), &reason), |
|
| 986 | 993 | IdentityCmd::Delegate { cmd } => match cmd { |
|
| 987 | - | DelegateCmd::Add { identity_file } => identity::delegate_add(&identity_file), |
|
| 994 | + | DelegateCmd::Add { |
|
| 995 | + | identity_file, |
|
| 996 | + | scope, |
|
| 997 | + | expires, |
|
| 998 | + | } => identity::delegate_add(&identity_file, &scope, expires.as_deref()), |
|
| 988 | 999 | DelegateCmd::Remove { key } => identity::delegate_remove(&key), |
|
| 989 | 1000 | DelegateCmd::List => identity::delegate_list(), |
|
| 990 | 1001 | }, |
|
| … | 91 unchanged lines |