Identity lifecycle: rotate, renew, delegates, revoke; expiry checks; policy roots, thresholds, proposals and oxy policy sign; policy chains served on refs and carried in push bundles (spec 0.6n) 28fc23d7706a
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-26 23:34 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
28fc23d7706a8c6c2208620d4d47f0051325166076682517f936466adcfefdb2- Depends on
- 096f447ccda0 132b884b9a8d 135212de5370 1983686fb7a7 23b217d75848 2863456a0f6d 5b9c6ad45f6e 648724d8e0de 6706bdcfb63e 750251b21a96 7a716eb94dee 83e0677b07fe 94084087a876 96f7610fa666 982864068a59 c3069537d7c5 d02e545dc265 fce44ae9ad29 ff0af1ee299d
- Depended on by
- aded4110f0bd 64dc5197f9df b63fc7a77fc6 d8524a9f0117 028545dfa7a4 767ac6cd0545 f0a517d28237 3a6effc74835 a39754d02c58 e5d9b0513f3e 530a0fc650a2 614f55e14eee 5cde7882d767 7ba155f20aa0 98a1620a3840 14edbaaf5291 ff1f62fee654 598009a75f6c 6f8aeb996558 fc1a466a1e66 6a81dbab471d 62794ecc7f74 d42369abf180 84b9e9ac0ab4 7aa2dc9369e3 75167672ebcd c6aa5bd9d1c8 957fee30826e 694805cca585 50a046e439e9 30ea9c1e70fa 6e723b81f5b9
- 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.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 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
- 106 atoms, 0 tree operations, 15 files
Browse the tree at this change
modified README.md +6 −0
| … | 60 unchanged lines |
||
| 61 | 61 | oxy push # publishes the policy |
|
| 62 | 62 | ``` |
|
| 63 | 63 | ||
| 64 | + | Keys have a lifecycle: `oxy identity rotate` moves to a fresh key pair (the policy follows), |
|
| 65 | + | `oxy identity renew --expires 365` sets an expiry, `oxy identity delegate add bot.identity` |
|
| 66 | + | lets another key sign for you, and `oxy identity revoke` retires a key. With several root |
|
| 67 | + | keys, `oxy policy root add|remove|threshold` and `oxy policy sign` gather the required signatures |
|
| 68 | + | through proposal files. |
|
| 69 | + | ||
| 64 | 70 | ## Layout |
|
| 65 | 71 | ||
| 66 | 72 | | Path | Contents | |
|
| … | 9 unchanged lines |
modified crates/oxy-core/src/attest.rs +11 −1
| … | 1 unchanged lines |
||
| 2 | 2 | //! of changes. Only the `ref` and `seal` kinds exist so far. |
|
| 3 | 3 | ||
| 4 | 4 | use crate::cbor::{self, Value}; |
|
| 5 | - | use crate::hash::{hash_object, ChangeHash, ObjectRef, ObjectType}; |
|
| 5 | + | use crate::hash::{KeyId, hash_object, ChangeHash, ObjectRef, ObjectType}; |
|
| 6 | 6 | use crate::objects::{array, bytes, check_version, field, map, oref, text, uint, Hlc, ObjectError, FORMAT_VERSION}; |
|
| 7 | 7 | ||
| 8 | 8 | pub const MAX_SEAL_HASHES: usize = 4096; |
|
| … | 6 unchanged lines |
||
| 15 | 15 | Seal { hashes: Vec<ChangeHash> }, |
|
| 16 | 16 | /// An immutable named state (spec §10.1). |
|
| 17 | 17 | Tag { name: String, state: ObjectRef, message: String }, |
|
| 18 | + | /// The signing key's identity was replaced by `new` (spec §14.2). |
|
| 19 | + | Rotate { new: KeyId }, |
|
| 20 | + | /// `key` must no longer be trusted (spec §14.2). |
|
| 21 | + | Revoke { key: KeyId, reason: String }, |
|
| 18 | 22 | } |
|
| 19 | 23 | ||
| 20 | 24 | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| … | 10 unchanged lines |
||
| 31 | 35 | AttestBody::Ref { .. } => "ref", |
|
| 32 | 36 | AttestBody::Seal { .. } => "seal", |
|
| 33 | 37 | AttestBody::Tag { .. } => "tag", |
|
| 38 | + | AttestBody::Rotate { .. } => "rotate", |
|
| 39 | + | AttestBody::Revoke { .. } => "revoke", |
|
| 34 | 40 | } |
|
| 35 | 41 | } |
|
| 36 | 42 | ||
| … | 17 unchanged lines |
||
| 54 | 60 | AttestBody::Tag { name, state, message } => { |
|
| 55 | 61 | Value::map([(0, Value::Text(name.clone())), (1, Value::Bytes(state.0.to_vec())), (2, Value::Text(message.clone()))]) |
|
| 56 | 62 | } |
|
| 63 | + | AttestBody::Rotate { new } => Value::map([(0, Value::Bytes(new.0.to_vec()))]), |
|
| 64 | + | AttestBody::Revoke { key, reason } => Value::map([(0, Value::Bytes(key.0.to_vec())), (1, Value::Text(reason.clone()))]), |
|
| 57 | 65 | }; |
|
| 58 | 66 | Value::map([ |
|
| 59 | 67 | (0, Value::Uint(FORMAT_VERSION)), |
|
| … | 41 unchanged lines |
||
| 101 | 109 | state: oref(field(b, 1, "state")?, "state")?, |
|
| 102 | 110 | message: text(field(b, 2, "message")?, "message")?.to_owned(), |
|
| 103 | 111 | }, |
|
| 112 | + | "rotate" => AttestBody::Rotate { new: oref(field(b, 0, "new")?, "new")? }, |
|
| 113 | + | "revoke" => AttestBody::Revoke { key: oref(field(b, 0, "key")?, "key")?, reason: text(field(b, 1, "reason")?, "reason")?.to_owned() }, |
|
| 104 | 114 | other => return Err(ObjectError::Schema(format!("unknown attestation kind {other:?}"))), |
|
| 105 | 115 | }; |
|
| 106 | 116 | Ok(Attest { repo, hlc, body }) |
|
| … | 33 unchanged lines |
modified crates/oxy-core/src/identity.rs +14 −0
| … | 76 unchanged lines |
||
| 77 | 77 | }) |
|
| 78 | 78 | } |
|
| 79 | 79 | ||
| 80 | + | /// Whether the identity is past its expiry at `now_ms`. |
|
| 81 | + | pub fn is_expired_at(&self, now_ms: u64) -> bool { |
|
| 82 | + | self.expires.is_some_and(|e| now_ms > e) |
|
| 83 | + | } |
|
| 84 | + | ||
| 85 | + | /// A successor identity with the same keys, `previous` set to this |
|
| 86 | + | /// identity's KeyId, and the given delegates and expiry (spec §14.2). |
|
| 87 | + | pub fn successor(&self, delegates: Vec<KeyId>, expires: Option<u64>, created: u64) -> Identity { |
|
| 88 | + | let mut delegates = delegates; |
|
| 89 | + | delegates.sort(); |
|
| 90 | + | delegates.dedup(); |
|
| 91 | + | Identity { previous: Some(self.hash()), delegates, expires, created, ..self.clone() } |
|
| 92 | + | } |
|
| 93 | + | ||
| 80 | 94 | fn ed_key(&self) -> Result<EdVerifyingKey, VerifyError> { |
|
| 81 | 95 | EdVerifyingKey::from_bytes(&self.ed25519_pk).map_err(|_| VerifyError::BadKey) |
|
| 82 | 96 | } |
|
| … | 263 unchanged lines |
modified crates/oxy-core/src/policy.rs +64 −0
| … | 210 unchanged lines |
||
| 211 | 211 | Ok(signers) |
|
| 212 | 212 | } |
|
| 213 | 213 | ||
| 214 | + | /// Bootstrap check for the first policy a repository sees (trust on |
|
| 215 | + | /// first use): at least one valid signature by one of its own roots. |
|
| 216 | + | /// The full threshold applies only to updates from a trusted policy, |
|
| 217 | + | /// whose signers are the previous roots and need not be the new ones. |
|
| 218 | + | pub fn verify_bootstrap(&self, sigs: &[Signature]) -> Result<KeyId, String> { |
|
| 219 | + | let target = self.hash(); |
|
| 220 | + | for sig in sigs { |
|
| 221 | + | if !self.roots.contains(&sig.key) || self.is_revoked(&sig.key) { |
|
| 222 | + | continue; |
|
| 223 | + | } |
|
| 224 | + | if let Some(id) = self.identity(&sig.key) { |
|
| 225 | + | if identity::verify(id, sig, Domain::Policy, &target).is_ok() { |
|
| 226 | + | return Ok(sig.key); |
|
| 227 | + | } |
|
| 228 | + | } |
|
| 229 | + | } |
|
| 230 | + | Err("policy carries no valid signature by one of its own root keys".into()) |
|
| 231 | + | } |
|
| 232 | + | ||
| 214 | 233 | /// A single-root policy letting `root` do everything on every reference. |
|
| 215 | 234 | pub fn single_root(repo: [u8; 16], root: Identity, expires: u64) -> Policy { |
|
| 216 | 235 | let key = root.hash(); |
|
| … | 9 unchanged lines |
||
| 226 | 245 | } |
|
| 227 | 246 | } |
|
| 228 | 247 | ||
| 248 | + | /// Whether `key` appears anywhere in the policy (roots, rules, identities). |
|
| 249 | + | pub fn mentions(&self, key: &KeyId) -> bool { |
|
| 250 | + | self.roots.contains(key) || self.rules.iter().any(|r| r.keys.contains(key)) || self.identity(key).is_some() |
|
| 251 | + | } |
|
| 252 | + | ||
| 253 | + | /// Whether `key` is `rule_key` or one of its predecessors in the |
|
| 254 | + | /// rotation chain (`previous` links, spec §14.2), so that changes signed |
|
| 255 | + | /// before a rotation remain authorized. Revoked keys never qualify. |
|
| 256 | + | pub fn succeeds(&self, rule_key: &KeyId, key: &KeyId) -> bool { |
|
| 257 | + | if self.is_revoked(key) { |
|
| 258 | + | return false; |
|
| 259 | + | } |
|
| 260 | + | let mut cur = Some(*rule_key); |
|
| 261 | + | for _ in 0..32 { |
|
| 262 | + | match cur { |
|
| 263 | + | Some(k) if k == *key => return true, |
|
| 264 | + | Some(k) => cur = self.identity(&k).and_then(|i| i.previous), |
|
| 265 | + | None => return false, |
|
| 266 | + | } |
|
| 267 | + | } |
|
| 268 | + | false |
|
| 269 | + | } |
|
| 270 | + | ||
| 271 | + | /// Replace `old` by `new` in the roots and every rule and embed `new`'s |
|
| 272 | + | /// identity (a rotation, spec §14.2). The old identity stays embedded so |
|
| 273 | + | /// signatures made before the rotation still verify; it is superseded, |
|
| 274 | + | /// not revoked. |
|
| 275 | + | pub fn substitute_key(&mut self, old: &KeyId, new: Identity) { |
|
| 276 | + | let nk = new.hash(); |
|
| 277 | + | for k in self.roots.iter_mut().chain(self.rules.iter_mut().flat_map(|r| r.keys.iter_mut())) { |
|
| 278 | + | if k == old { |
|
| 279 | + | *k = nk; |
|
| 280 | + | } |
|
| 281 | + | } |
|
| 282 | + | for r in &mut self.rules { |
|
| 283 | + | r.keys.sort(); |
|
| 284 | + | r.keys.dedup(); |
|
| 285 | + | } |
|
| 286 | + | self.roots.sort(); |
|
| 287 | + | self.roots.dedup(); |
|
| 288 | + | if self.identity(&nk).is_none() { |
|
| 289 | + | self.identities.push(new); |
|
| 290 | + | } |
|
| 291 | + | } |
|
| 292 | + | ||
| 229 | 293 | /// Identities keyed by KeyId. |
|
| 230 | 294 | pub fn identity_map(&self) -> BTreeMap<KeyId, &Identity> { |
|
| 231 | 295 | self.identities.iter().map(|i| (i.hash(), i)).collect() |
|
| … | 79 unchanged lines |
modified crates/oxy-core/src/protocol.rs +48 −14
| … | 32 unchanged lines |
||
| 33 | 33 | pub sigs: Vec<Signature>, |
|
| 34 | 34 | } |
|
| 35 | 35 | ||
| 36 | + | impl SignedPolicy { |
|
| 37 | + | /// The proposal-file encoding (`wire::policy_value`). |
|
| 38 | + | pub fn encode(&self) -> Vec<u8> { |
|
| 39 | + | crate::cbor::encode(&wire::policy_value(self)) |
|
| 40 | + | } |
|
| 41 | + | pub fn decode(b: &[u8]) -> Result<SignedPolicy> { |
|
| 42 | + | wire::policy_from(&crate::cbor::decode(b).map_err(|e| RepoError::Object(ObjectError::from(e)))?) |
|
| 43 | + | } |
|
| 44 | + | } |
|
| 45 | + | ||
| 36 | 46 | /// What `refs` returns: every bookmark, every tag, and the current policy. |
|
| 37 | 47 | #[derive(Clone, Debug, Default)] |
|
| 38 | 48 | pub struct RefsResponse { |
|
| 39 | 49 | pub refs: Vec<RemoteRef>, |
|
| 40 | 50 | pub policy: Option<SignedPolicy>, |
|
| 41 | 51 | pub tags: Vec<TagRef>, |
|
| 52 | + | /// Earlier policies, ascending by serial, so a client behind by more |
|
| 53 | + | /// than one update can verify each step (spec §14.5). |
|
| 54 | + | pub history: Vec<SignedPolicy>, |
|
| 42 | 55 | } |
|
| 43 | 56 | ||
| 44 | 57 | /// A tag as a remote reports it (spec §10.1). |
|
| … | 23 unchanged lines |
||
| 68 | 81 | pub attests: Vec<SignedAttest>, |
|
| 69 | 82 | /// Chunks, blobs, identities, and state objects referenced by the changes. |
|
| 70 | 83 | pub objects: Vec<(ObjectType, Vec<u8>)>, |
|
| 84 | + | /// Policies between the receiver's and the one sent with the push, |
|
| 85 | + | /// ascending by serial (spec §14.5). |
|
| 86 | + | pub policies: Vec<SignedPolicy>, |
|
| 71 | 87 | } |
|
| 72 | 88 | ||
| 73 | 89 | impl Bundle { |
|
| … | 232 unchanged lines |
||
| 306 | 322 | fn opt_attest_from(v: &Value) -> Result<Option<SignedAttest>> { |
|
| 307 | 323 | if v.is_null() { Ok(None) } else { attest_from(v).map(Some) } |
|
| 308 | 324 | } |
|
| 309 | - | fn opt_policy_value(sp: Option<&SignedPolicy>) -> Value { |
|
| 310 | - | match sp { |
|
| 311 | - | Some(sp) => Value::Array(vec![Value::Bytes(sp.policy.encode()), sigs_value(&sp.sigs)]), |
|
| 312 | - | None => Value::Null, |
|
| 313 | - | } |
|
| 325 | + | /// `[policy bytes, [signature]]`: also the format of a policy proposal |
|
| 326 | + | /// file exchanged between root holders (spec §14.5). |
|
| 327 | + | pub fn policy_value(sp: &SignedPolicy) -> Value { |
|
| 328 | + | Value::Array(vec![Value::Bytes(sp.policy.encode()), sigs_value(&sp.sigs)]) |
|
| 314 | 329 | } |
|
| 315 | - | fn opt_policy_from(v: &Value) -> Result<Option<SignedPolicy>> { |
|
| 316 | - | if v.is_null() { |
|
| 317 | - | return Ok(None); |
|
| 318 | - | } |
|
| 330 | + | pub fn policy_from(v: &Value) -> Result<SignedPolicy> { |
|
| 319 | 331 | let a = o::array(v, "signed policy")?; |
|
| 320 | 332 | if a.len() != 2 { |
|
| 321 | 333 | return Err(schema("signed policy: expected [policy, sigs]")); |
|
| 322 | 334 | } |
|
| 323 | - | Ok(Some(SignedPolicy { policy: Policy::decode(o::bytes(&a[0], "policy")?)?, sigs: sigs_from(&a[1])? })) |
|
| 335 | + | Ok(SignedPolicy { policy: Policy::decode(o::bytes(&a[0], "policy")?)?, sigs: sigs_from(&a[1])? }) |
|
| 336 | + | } |
|
| 337 | + | fn opt_policy_value(sp: Option<&SignedPolicy>) -> Value { |
|
| 338 | + | sp.map(policy_value).unwrap_or(Value::Null) |
|
| 339 | + | } |
|
| 340 | + | fn opt_policy_from(v: &Value) -> Result<Option<SignedPolicy>> { |
|
| 341 | + | if v.is_null() { Ok(None) } else { policy_from(v).map(Some) } |
|
| 324 | 342 | } |
|
| 325 | 343 | fn map<'a>(v: &'a Value, what: &str) -> Result<&'a o::Map> { |
|
| 326 | 344 | Ok(o::map(v, what)?) |
|
| … | 15 unchanged lines |
||
| 342 | 360 | b.objects.iter().map(|(t, body)| Value::Array(vec![Value::Text(t.name().into()), Value::Bytes(body.clone())])).collect(), |
|
| 343 | 361 | ), |
|
| 344 | 362 | ), |
|
| 363 | + | (3, Value::Array(b.policies.iter().map(policy_value).collect())), |
|
| 345 | 364 | ]) |
|
| 346 | 365 | } |
|
| 347 | 366 | ||
| … | 18 unchanged lines |
||
| 366 | 385 | let ty = ObjectType::from_name(o::text(&pair[0], "object type")?).ok_or_else(|| schema("unknown object type"))?; |
|
| 367 | 386 | b.objects.push((ty, o::bytes(&pair[1], "object body")?.to_vec())); |
|
| 368 | 387 | } |
|
| 388 | + | if let Some(ps) = m.get(&3) { |
|
| 389 | + | for p in o::array(ps, "policies")? { |
|
| 390 | + | b.policies.push(policy_from(p)?); |
|
| 391 | + | } |
|
| 392 | + | } |
|
| 369 | 393 | Ok(b) |
|
| 370 | 394 | } |
|
| 371 | 395 | ||
| … | 71 unchanged lines |
||
| 443 | 467 | (1, Value::Bytes(id.to_vec())), |
|
| 444 | 468 | (2, opt_policy_value(resp.policy.as_ref())), |
|
| 445 | 469 | (3, tags_value(&resp.tags)), |
|
| 470 | + | (4, Value::Array(resp.history.iter().map(policy_value).collect())), |
|
| 446 | 471 | ])) |
|
| 447 | 472 | } |
|
| 448 | 473 | pub fn decode_refs(bytes: &[u8]) -> Result<([u8; 16], RefsResponse)> { |
|
| … | 20 unchanged lines |
||
| 469 | 494 | Some(t) => tags_from(t)?, |
|
| 470 | 495 | None => Vec::new(), |
|
| 471 | 496 | }; |
|
| 472 | - | Ok((id, RefsResponse { refs, policy, tags })) |
|
| 497 | + | let mut history = Vec::new(); |
|
| 498 | + | if let Some(h) = m.get(&4) { |
|
| 499 | + | for p in o::array(h, "history")? { |
|
| 500 | + | history.push(policy_from(p)?); |
|
| 501 | + | } |
|
| 502 | + | } |
|
| 503 | + | Ok((id, RefsResponse { refs, policy, tags, history })) |
|
| 473 | 504 | } |
|
| 474 | 505 | ||
| 475 | 506 | pub fn encode_negotiate(have: &[ChangeHash], want: &[ChangeHash]) -> Vec<u8> { |
|
| … | 132 unchanged lines |
||
| 608 | 639 | let sig = sk.sign(crate::identity::Domain::Change, &key, &c.hash()); |
|
| 609 | 640 | let seal = Attest::seal([3; 16], vec![c.hash()]); |
|
| 610 | 641 | let seal_sig = sk.sign(crate::identity::Domain::Attest, &key, &seal.hash()); |
|
| 642 | + | let policy = Policy::single_root([3; 16], id.clone(), 99); |
|
| 643 | + | let psig = sk.sign(crate::identity::Domain::Policy, &key, &policy.hash()); |
|
| 611 | 644 | let bundle = Bundle { |
|
| 612 | 645 | changes: vec![(c.clone(), vec![sig.clone()])], |
|
| 613 | 646 | attests: vec![SignedAttest { attest: seal.clone(), sigs: vec![seal_sig] }], |
|
| 614 | 647 | objects: vec![(ObjectType::Identity, id.encode()), (ObjectType::Chunk, b"xyz".to_vec())], |
|
| 648 | + | policies: vec![SignedPolicy { policy: policy.clone(), sigs: vec![psig.clone()] }], |
|
| 615 | 649 | }; |
|
| 616 | 650 | let d = wire::decode_bundle(&wire::encode_bundle(&bundle)).unwrap(); |
|
| 617 | 651 | assert_eq!(d.changes, bundle.changes); |
|
| 618 | 652 | assert_eq!(d.attests, bundle.attests); |
|
| 619 | 653 | assert_eq!(d.objects, bundle.objects); |
|
| 654 | + | assert_eq!(d.policies, bundle.policies); |
|
| 620 | 655 | ||
| 621 | 656 | let att = Attest { repo: [3; 16], hlc: Hlc::default(), body: AttestBody::Ref { name: "main".into(), old_state: ObjectRef([1; 32]), new_state: ObjectRef([2; 32]), serial: 4 } }; |
|
| 622 | 657 | let refs = vec![ |
|
| 623 | 658 | RemoteRef { name: "main".into(), state: ObjectRef([2; 32]), heads: vec![c.hash()], attestation: Some(SignedAttest { attest: att.clone(), sigs: vec![sig.clone()] }), last_serial: Some(4) }, |
|
| 624 | 659 | RemoteRef { name: "dev".into(), state: ObjectRef([5; 32]), heads: vec![], attestation: None, last_serial: None }, |
|
| 625 | 660 | ]; |
|
| 626 | - | let policy = Policy::single_root([3; 16], id.clone(), 99); |
|
| 627 | - | let psig = sk.sign(crate::identity::Domain::Policy, &key, &policy.hash()); |
|
| 628 | 661 | let tag = TagRef { name: "v1".into(), state: ObjectRef([2; 32]), heads: vec![c.hash()], attestation: None }; |
|
| 629 | - | let resp = RefsResponse { refs, policy: Some(SignedPolicy { policy: policy.clone(), sigs: vec![psig.clone()] }), tags: vec![tag.clone()] }; |
|
| 662 | + | let resp = RefsResponse { refs, policy: Some(SignedPolicy { policy: policy.clone(), sigs: vec![psig.clone()] }), tags: vec![tag.clone()], history: vec![SignedPolicy { policy: policy.clone(), sigs: vec![] }] }; |
|
| 630 | 663 | let (id_back, back) = wire::decode_refs(&wire::encode_refs([3; 16], &resp)).unwrap(); |
|
| 631 | 664 | assert_eq!(id_back, [3; 16]); |
|
| 632 | 665 | assert_eq!(back.refs.len(), 2); |
|
| 633 | 666 | assert_eq!(back.refs[0].attestation.as_ref().unwrap().attest, att); |
|
| 634 | 667 | assert_eq!(back.refs[1].last_serial, None); |
|
| 635 | 668 | assert_eq!(back.policy.as_ref().unwrap().policy, policy); |
|
| 669 | + | assert_eq!(back.history, resp.history); |
|
| 636 | 670 | assert_eq!(back.tags, vec![tag.clone()]); |
|
| 637 | 671 | ||
| 638 | 672 | let (h, w) = wire::decode_negotiate(&wire::encode_negotiate(&[ObjectRef([1; 32])], &[ObjectRef([2; 32])])).unwrap(); |
|
| … | 22 unchanged lines |
modified crates/oxy-core/src/repo/identity.rs +166 −10
| … | 5 unchanged lines |
||
| 6 | 6 | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 7 | 7 | pub enum SignatureStatus { |
|
| 8 | 8 | Unsigned, |
|
| 9 | - | Valid { key: KeyId, name: String, email: String, sealed: bool }, |
|
| 9 | + | /// `revoked` marks a signature that verifies but whose key has since |
|
| 10 | + | /// been revoked by policy or by a revocation this repository recorded. |
|
| 11 | + | Valid { key: KeyId, name: String, email: String, sealed: bool, revoked: bool }, |
|
| 10 | 12 | Invalid(String), |
|
| 11 | 13 | } |
|
| 12 | 14 | ||
| 15 | + | /// The outcome of replacing the configured identity (spec §14.2). |
|
| 16 | + | #[derive(Clone, Debug)] |
|
| 17 | + | pub struct Rotation { |
|
| 18 | + | pub old: KeyId, |
|
| 19 | + | pub new: KeyId, |
|
| 20 | + | pub identity: Identity, |
|
| 21 | + | /// The policy update that moved the old key's grants to the new key, |
|
| 22 | + | /// when the repository has a policy naming the old key. |
|
| 23 | + | pub policy: Option<PolicyUpdate>, |
|
| 24 | + | } |
|
| 25 | + | ||
| 13 | 26 | impl Repo { |
|
| 14 | 27 | // ------------------------------------------------------------ identity |
|
| 15 | 28 | ||
| 16 | 29 | /// Generate a composite key pair, store its identity object, keep the |
|
| 17 | 30 | /// secret in the key directory, and record the author at `scope`. |
|
| 18 | 31 | pub fn create_identity(&mut self, name: &str, email: &str, scope: IdentityScope) -> Result<(KeyId, Identity)> { |
|
| 32 | + | self.create_identity_with(name, email, scope, None) |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | /// `create_identity` with an expiry (milliseconds since the epoch). |
|
| 36 | + | pub fn create_identity_with(&mut self, name: &str, email: &str, scope: IdentityScope, expires: Option<u64>) -> Result<(KeyId, Identity)> { |
|
| 19 | 37 | let sk = SecretKey::generate(); |
|
| 20 | - | let id = sk.identity(name, email, Hlc::now().wall_ms); |
|
| 21 | - | let key = self.store.put_identity(&id)?; |
|
| 38 | + | let mut id = sk.identity(name, email, Hlc::now().wall_ms); |
|
| 39 | + | id.expires = expires; |
|
| 40 | + | let key = self.install_key(&id, &sk)?; |
|
| 41 | + | self.set_author(&id, &key, scope)?; |
|
| 42 | + | Ok((key, id)) |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | /// Store an identity and keep its secret (and a copy of the identity) |
|
| 46 | + | /// in the key directory. |
|
| 47 | + | fn install_key(&self, id: &Identity, sk: &SecretKey) -> Result<KeyId> { |
|
| 48 | + | let key = self.store.put_identity(id)?; |
|
| 22 | 49 | fs::create_dir_all(&self.keys_dir)?; |
|
| 23 | 50 | let path = self.keys_dir.join(format!("{}.secret", key.to_hex())); |
|
| 24 | 51 | write_atomic(&path, &sk.encode(&key))?; |
|
| … | 3 unchanged lines |
||
| 28 | 55 | use std::os::unix::fs::PermissionsExt; |
|
| 29 | 56 | fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; |
|
| 30 | 57 | } |
|
| 58 | + | Ok(key) |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | fn set_author(&mut self, id: &Identity, key: &KeyId, scope: IdentityScope) -> Result<()> { |
|
| 31 | 62 | match scope { |
|
| 32 | 63 | IdentityScope::Repo => { |
|
| 33 | - | self.config.set("author.name", name)?; |
|
| 34 | - | self.config.set("author.email", email)?; |
|
| 64 | + | self.config.set("author.name", &id.name)?; |
|
| 65 | + | self.config.set("author.email", &id.email)?; |
|
| 35 | 66 | self.config.set("author.key", &key.to_hex())?; |
|
| 36 | 67 | self.save_config()?; |
|
| 37 | 68 | } |
|
| 38 | 69 | IdentityScope::User => { |
|
| 39 | - | Config::set_user(&self.user_config, "author.name", name)?; |
|
| 40 | - | Config::set_user(&self.user_config, "author.email", email)?; |
|
| 70 | + | Config::set_user(&self.user_config, "author.name", &id.name)?; |
|
| 71 | + | Config::set_user(&self.user_config, "author.email", &id.email)?; |
|
| 41 | 72 | Config::set_user(&self.user_config, "author.key", &key.to_hex())?; |
|
| 42 | 73 | self.config = Config::load(Some(&self.user_config), &self.oxy.join("config"))?; |
|
| 43 | 74 | } |
|
| 44 | 75 | } |
|
| 45 | - | Ok((key, id)) |
|
| 76 | + | Ok(()) |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | /// The scope that holds the configured author key. |
|
| 80 | + | fn author_scope(&self) -> IdentityScope { |
|
| 81 | + | if self.config.repo_layer().contains_key("author.key") { IdentityScope::Repo } else { IdentityScope::User } |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | /// Replace the configured identity by `new` signed with `sk`: store it, |
|
| 85 | + | /// record a `rotate` attestation signed by the old key, move the old |
|
| 86 | + | /// key's policy grants to the new key, and switch the author config. |
|
| 87 | + | fn replace_identity(&mut self, new: Identity, sk: SecretKey, command: &str) -> Result<Rotation> { |
|
| 88 | + | let Some((old, old_sk)) = self.secret_key()? else { |
|
| 89 | + | return fail("no secret key: run `oxy identity create` first"); |
|
| 90 | + | }; |
|
| 91 | + | if new.previous != Some(old) { |
|
| 92 | + | return fail("the successor identity must name the current key as previous"); |
|
| 93 | + | } |
|
| 94 | + | let new_key = self.install_key(&new, &sk)?; |
|
| 95 | + | let attest = Attest { repo: self.id()?, hlc: Hlc::now(), body: AttestBody::Rotate { new: new_key } }; |
|
| 96 | + | let r = self.store.put_attest(&attest)?; |
|
| 97 | + | self.store.put_signature(&r, &old_sk.sign(Domain::Attest, &old, &r))?; |
|
| 98 | + | let policy = match self.current_policy()? { |
|
| 99 | + | Some((_, sp)) if sp.policy.mentions(&old) => { |
|
| 100 | + | let mut p = self.next_policy()?; |
|
| 101 | + | p.substitute_key(&old, new.clone()); |
|
| 102 | + | Some(self.sign_policy_with(p, &[(old, &old_sk), (new_key, &sk)], command)?) |
|
| 103 | + | } |
|
| 104 | + | _ => None, |
|
| 105 | + | }; |
|
| 106 | + | self.set_author(&new, &new_key, self.author_scope())?; |
|
| 107 | + | Ok(Rotation { old, new: new_key, identity: new, policy }) |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | /// Rotate to a fresh key pair; the new identity names the old one as |
|
| 111 | + | /// `previous` and keeps its name, email, delegates, and expiry. |
|
| 112 | + | pub fn rotate_identity(&mut self) -> Result<Rotation> { |
|
| 113 | + | let Some((old, _)) = self.secret_key()? else { |
|
| 114 | + | return fail("no secret key: run `oxy identity create` first"); |
|
| 115 | + | }; |
|
| 116 | + | let cur = self.identity(&old)?; |
|
| 117 | + | let sk = SecretKey::generate(); |
|
| 118 | + | let mut id = sk.identity(&cur.name, &cur.email, Hlc::now().wall_ms); |
|
| 119 | + | id.previous = Some(old); |
|
| 120 | + | id.delegates = cur.delegates.clone(); |
|
| 121 | + | id.expires = cur.expires; |
|
| 122 | + | self.replace_identity(id, sk, "oxy identity rotate") |
|
| 123 | + | } |
|
| 124 | + | ||
| 125 | + | /// Re-issue the identity with the same keys and a new expiry. |
|
| 126 | + | pub fn renew_identity(&mut self, expires: Option<u64>) -> Result<Rotation> { |
|
| 127 | + | let Some((old, sk)) = self.secret_key()? else { |
|
| 128 | + | return fail("no secret key: run `oxy identity create` first"); |
|
| 129 | + | }; |
|
| 130 | + | let cur = self.identity(&old)?; |
|
| 131 | + | let id = cur.successor(cur.delegates.clone(), expires, Hlc::now().wall_ms); |
|
| 132 | + | self.replace_identity(id, sk, "oxy identity renew") |
|
| 133 | + | } |
|
| 134 | + | ||
| 135 | + | /// Let `delegate` sign or seal on this identity's behalf. Delegates are |
|
| 136 | + | /// part of the identity object, so this re-issues it with the same keys. |
|
| 137 | + | pub fn delegate_add(&mut self, delegate: Identity) -> Result<Rotation> { |
|
| 138 | + | let Some((old, sk)) = self.secret_key()? else { |
|
| 139 | + | return fail("no secret key: run `oxy identity create` first"); |
|
| 140 | + | }; |
|
| 141 | + | let dk = self.store.put_identity(&delegate)?; |
|
| 142 | + | if dk == old { |
|
| 143 | + | return fail("an identity cannot delegate to itself"); |
|
| 144 | + | } |
|
| 145 | + | let cur = self.identity(&old)?; |
|
| 146 | + | if cur.delegates.contains(&dk) { |
|
| 147 | + | return fail(format!("{} is already a delegate", dk.short(12))); |
|
| 148 | + | } |
|
| 149 | + | let mut delegates = cur.delegates.clone(); |
|
| 150 | + | delegates.push(dk); |
|
| 151 | + | let id = cur.successor(delegates, cur.expires, Hlc::now().wall_ms); |
|
| 152 | + | self.replace_identity(id, sk, &format!("oxy identity delegate add {}", dk.short(12))) |
|
| 153 | + | } |
|
| 154 | + | ||
| 155 | + | /// Withdraw a delegation; re-issues the identity with the same keys. |
|
| 156 | + | pub fn delegate_remove(&mut self, delegate: &KeyId) -> Result<Rotation> { |
|
| 157 | + | let Some((old, sk)) = self.secret_key()? else { |
|
| 158 | + | return fail("no secret key: run `oxy identity create` first"); |
|
| 159 | + | }; |
|
| 160 | + | let cur = self.identity(&old)?; |
|
| 161 | + | if !cur.delegates.contains(delegate) { |
|
| 162 | + | return fail(format!("{} is not a delegate", delegate.short(12))); |
|
| 163 | + | } |
|
| 164 | + | let delegates = cur.delegates.iter().copied().filter(|d| d != delegate).collect(); |
|
| 165 | + | let id = cur.successor(delegates, cur.expires, Hlc::now().wall_ms); |
|
| 166 | + | self.replace_identity(id, sk, &format!("oxy identity delegate remove {}", delegate.short(12))) |
|
| 167 | + | } |
|
| 168 | + | ||
| 169 | + | /// Revoke `key` (your own, or any key when you hold a policy root): |
|
| 170 | + | /// record a signed `revoke` attestation and a local marker, and drop |
|
| 171 | + | /// the key from the policy where it has one. |
|
| 172 | + | pub fn revoke_identity(&self, key: &KeyId, reason: &str) -> Result<Option<PolicyUpdate>> { |
|
| 173 | + | let Some((own, sk)) = self.secret_key()? else { |
|
| 174 | + | return fail("no secret key: run `oxy identity create` first"); |
|
| 175 | + | }; |
|
| 176 | + | let policy = self.current_policy()?.map(|(_, sp)| sp.policy); |
|
| 177 | + | let is_root = policy.as_ref().is_some_and(|p| p.roots.contains(&own)); |
|
| 178 | + | if *key != own && !is_root { |
|
| 179 | + | return fail("only the key's holder or a policy root can revoke it"); |
|
| 180 | + | } |
|
| 181 | + | let attest = Attest { repo: self.id()?, hlc: Hlc::now(), body: AttestBody::Revoke { key: *key, reason: reason.into() } }; |
|
| 182 | + | let r = self.store.put_attest(&attest)?; |
|
| 183 | + | self.store.put_signature(&r, &sk.sign(Domain::Attest, &own, &r))?; |
|
| 184 | + | let dir = self.oxy.join("revoked"); |
|
| 185 | + | fs::create_dir_all(&dir)?; |
|
| 186 | + | write_atomic(&dir.join(key.to_hex()), r.to_hex().as_bytes())?; |
|
| 187 | + | match policy { |
|
| 188 | + | Some(p) if p.mentions(key) => Ok(Some(self.policy_revoke(key)?)), |
|
| 189 | + | _ => Ok(None), |
|
| 190 | + | } |
|
| 191 | + | } |
|
| 192 | + | ||
| 193 | + | /// Whether `key` is revoked by the policy or by a recorded revocation. |
|
| 194 | + | pub fn is_revoked(&self, key: &KeyId) -> Result<bool> { |
|
| 195 | + | if self.oxy.join("revoked").join(key.to_hex()).exists() { |
|
| 196 | + | return Ok(true); |
|
| 197 | + | } |
|
| 198 | + | Ok(self.current_policy()?.is_some_and(|(_, sp)| sp.policy.is_revoked(key))) |
|
| 46 | 199 | } |
|
| 47 | 200 | ||
| 48 | 201 | /// The secret key for `author.key`, if configured and present on disk. |
|
| … | 48 unchanged lines |
||
| 97 | 250 | let mut last_err = String::new(); |
|
| 98 | 251 | for sig in &sigs { |
|
| 99 | 252 | match self.check_signer(&change, sig, Domain::Change, hash) { |
|
| 100 | - | Ok(id) => return Ok(SignatureStatus::Valid { key: sig.key, name: id.name, email: id.email, sealed: false }), |
|
| 253 | + | Ok(id) => return Ok(SignatureStatus::Valid { key: sig.key, name: id.name, email: id.email, sealed: false, revoked: self.is_revoked(&sig.key)? }), |
|
| 101 | 254 | Err(e) => last_err = e, |
|
| 102 | 255 | } |
|
| 103 | 256 | } |
|
| … | 8 unchanged lines |
||
| 112 | 265 | if !authorised { |
|
| 113 | 266 | return Err(format!("key {} is neither the author nor a delegate", sig.key.short(12))); |
|
| 114 | 267 | } |
|
| 268 | + | if id.is_expired_at(change.hlc.wall_ms) { |
|
| 269 | + | return Err(format!("key {} had expired when the change was made", sig.key.short(12))); |
|
| 270 | + | } |
|
| 115 | 271 | verify_signature(&id, sig, domain, target).map_err(|e| e.to_string())?; |
|
| 116 | 272 | Ok(id) |
|
| 117 | 273 | } |
|
| … | 18 unchanged lines |
||
| 136 | 292 | } |
|
| 137 | 293 | for sig in self.store.signatures(&seal_ref)? { |
|
| 138 | 294 | match self.check_signer(&change, &sig, Domain::Attest, &seal_ref) { |
|
| 139 | - | Ok(id) => return Ok(SignatureStatus::Valid { key: sig.key, name: id.name, email: id.email, sealed: true }), |
|
| 295 | + | Ok(id) => return Ok(SignatureStatus::Valid { key: sig.key, name: id.name, email: id.email, sealed: true, revoked: self.is_revoked(&sig.key)? }), |
|
| 140 | 296 | Err(e) => last_err = e, |
|
| 141 | 297 | } |
|
| 142 | 298 | } |
|
| … | 20 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +2 −1
| … | 73 unchanged lines |
||
| 74 | 74 | pub use hooks::{json_array, json_str, HookOutcome}; |
|
| 75 | 75 | pub use prompt::{PromptInfo, DEFAULT_PROMPT_FORMAT}; |
|
| 76 | 76 | pub use git::{GitExportReport, GitImportReport}; |
|
| 77 | - | pub use identity::SignatureStatus; |
|
| 77 | + | pub use identity::{Rotation, SignatureStatus}; |
|
| 78 | + | pub use policy::PolicyUpdate; |
|
| 78 | 79 | pub use snapshot::{added_node_paths, has_conflict_markers, is_binary, referenced_changes, render_change, ChangeStatus, PathStatus, SnapshotResult}; |
|
| 79 | 80 | pub use sync::{FetchReport, PullReport}; |
|
| 80 | 81 | pub use tags::TagInfo; |
|
| … | 628 unchanged lines |
modified crates/oxy-core/src/repo/policy.rs +173 −25
| … | 5 unchanged lines |
||
| 6 | 6 | //! policy must be signed by a threshold of the roots of the policy it replaces. |
|
| 7 | 7 | ||
| 8 | 8 | use super::*; |
|
| 9 | + | use crate::identity::SecretKey; |
|
| 9 | 10 | use crate::policy::{Policy, Require, Rule}; |
|
| 10 | 11 | use crate::protocol::SignedPolicy; |
|
| 11 | 12 | ||
| 12 | 13 | /// One year, the default policy lifetime. |
|
| 13 | 14 | const POLICY_LIFETIME_MS: u64 = 365 * 24 * 60 * 60 * 1000; |
|
| 14 | 15 | ||
| 16 | + | /// A signed policy update: applied when it met the root threshold, else a |
|
| 17 | + | /// proposal carrying the signatures gathered so far (spec §14.5). |
|
| 18 | + | #[derive(Clone, Debug)] |
|
| 19 | + | pub struct PolicyUpdate { |
|
| 20 | + | pub policy: Policy, |
|
| 21 | + | pub sigs: Vec<Signature>, |
|
| 22 | + | pub applied: bool, |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | impl PolicyUpdate { |
|
| 26 | + | pub fn signed(&self) -> SignedPolicy { |
|
| 27 | + | SignedPolicy { policy: self.policy.clone(), sigs: self.sigs.clone() } |
|
| 28 | + | } |
|
| 29 | + | } |
|
| 30 | + | ||
| 15 | 31 | impl Repo { |
|
| 16 | 32 | /// The current policy and its signatures, if the repository has one. |
|
| 17 | 33 | pub fn current_policy(&self) -> Result<Option<(ObjectRef, SignedPolicy)>> { |
|
| … | 11 unchanged lines |
||
| 29 | 45 | for id in &sp.policy.identities { |
|
| 30 | 46 | self.store.put_identity(id)?; |
|
| 31 | 47 | } |
|
| 48 | + | let dir = self.oxy.join("policies"); |
|
| 49 | + | fs::create_dir_all(&dir)?; |
|
| 50 | + | write_atomic(&dir.join(format!("{:020}", sp.policy.serial)), r.to_hex().as_bytes())?; |
|
| 32 | 51 | let (op_ref, op) = self.head_op()?; |
|
| 33 | 52 | if op.view.policy != Some(r) { |
|
| 34 | 53 | let mut view = op.view.clone(); |
|
| … | 3 unchanged lines |
||
| 38 | 57 | Ok(r) |
|
| 39 | 58 | } |
|
| 40 | 59 | ||
| 60 | + | /// Every policy this repository has applied, ascending by serial, with |
|
| 61 | + | /// its signatures. Policies applied before this index existed are |
|
| 62 | + | /// missing from it, except the current one. |
|
| 63 | + | pub fn policy_history(&self) -> Result<Vec<SignedPolicy>> { |
|
| 64 | + | let mut out: BTreeMap<u64, SignedPolicy> = BTreeMap::new(); |
|
| 65 | + | if let Ok(rd) = fs::read_dir(self.oxy.join("policies")) { |
|
| 66 | + | for e in rd.flatten() { |
|
| 67 | + | let Ok(hex) = fs::read_to_string(e.path()) else { continue }; |
|
| 68 | + | let Some(r) = ObjectRef::from_hex(hex.trim()) else { continue }; |
|
| 69 | + | let Ok(bytes) = self.store.get_typed(&r, ObjectType::Policy) else { continue }; |
|
| 70 | + | let policy = Policy::decode(&bytes)?; |
|
| 71 | + | out.insert(policy.serial, SignedPolicy { policy, sigs: self.store.signatures(&r)? }); |
|
| 72 | + | } |
|
| 73 | + | } |
|
| 74 | + | if let Some((_, sp)) = self.current_policy()? { |
|
| 75 | + | out.insert(sp.policy.serial, sp); |
|
| 76 | + | } |
|
| 77 | + | Ok(out.into_values().collect()) |
|
| 78 | + | } |
|
| 79 | + | ||
| 80 | + | /// Verify and apply a chain of policies ending in `newest`, each |
|
| 81 | + | /// against the one before it (spec §14.5). `chain` holds earlier |
|
| 82 | + | /// policies in any order; only those after the current serial are used. |
|
| 83 | + | pub(crate) fn adopt_policy_chain(&self, chain: &[SignedPolicy], newest: &SignedPolicy, command: &str) -> std::result::Result<(), String> { |
|
| 84 | + | let mut current = self.current_policy().map_err(|e| e.to_string())?.map(|(_, sp)| sp.policy); |
|
| 85 | + | let mut steps: Vec<&SignedPolicy> = chain |
|
| 86 | + | .iter() |
|
| 87 | + | .filter(|sp| current.as_ref().is_none_or(|c| sp.policy.serial > c.serial) && sp.policy.serial < newest.policy.serial) |
|
| 88 | + | .collect(); |
|
| 89 | + | steps.sort_by_key(|sp| sp.policy.serial); |
|
| 90 | + | steps.push(newest); |
|
| 91 | + | for sp in steps { |
|
| 92 | + | self.check_policy(sp, current.as_ref())?; |
|
| 93 | + | self.set_policy(sp, &format!("{command} (policy serial {})", sp.policy.serial)).map_err(|e| e.to_string())?; |
|
| 94 | + | current = Some(sp.policy.clone()); |
|
| 95 | + | } |
|
| 96 | + | Ok(()) |
|
| 97 | + | } |
|
| 98 | + | ||
| 41 | 99 | /// Validate an incoming policy against the one we trust (`None` when |
|
| 42 | 100 | /// bootstrapping: the policy then vouches for itself). |
|
| 43 | 101 | pub fn check_policy(&self, sp: &SignedPolicy, trusted: Option<&Policy>) -> std::result::Result<(), String> { |
|
| … | 5 unchanged lines |
||
| 49 | 107 | return Err(format!("policy serial {} is not greater than the current {}", sp.policy.serial, t.serial)); |
|
| 50 | 108 | } |
|
| 51 | 109 | } |
|
| 52 | - | sp.policy.verify_signatures(&sp.sigs, trusted.unwrap_or(&sp.policy))?; |
|
| 110 | + | match trusted { |
|
| 111 | + | Some(t) => sp.policy.verify_signatures(&sp.sigs, t)?, |
|
| 112 | + | None => vec![sp.policy.verify_bootstrap(&sp.sigs)?], |
|
| 113 | + | }; |
|
| 53 | 114 | Ok(()) |
|
| 54 | 115 | } |
|
| 55 | 116 | ||
| 56 | - | fn sign_policy(&self, policy: Policy) -> Result<SignedPolicy> { |
|
| 117 | + | /// Sign `policy` with the configured key and apply it if the current |
|
| 118 | + | /// policy's root threshold is met; otherwise return it as a proposal. |
|
| 119 | + | fn sign_policy(&self, policy: Policy, command: &str) -> Result<PolicyUpdate> { |
|
| 57 | 120 | let Some((key, sk)) = self.secret_key()? else { |
|
| 58 | 121 | return fail("no secret key: run `oxy identity create` first"); |
|
| 59 | 122 | }; |
|
| 60 | - | if !policy.roots.contains(&key) { |
|
| 61 | - | return fail("your key is not a root key of this policy"); |
|
| 123 | + | self.sign_policy_with(policy, &[(key, &sk)], command) |
|
| 124 | + | } |
|
| 125 | + | ||
| 126 | + | /// Sign with every listed key; the first must be a root of the current |
|
| 127 | + | /// policy (the others may be roots of the new policy only, so that a |
|
| 128 | + | /// fresh clone can bootstrap from it). |
|
| 129 | + | pub(crate) fn sign_policy_with(&self, policy: Policy, signers: &[(KeyId, &SecretKey)], command: &str) -> Result<PolicyUpdate> { |
|
| 130 | + | let trusted = self.current_policy()?.map(|(_, sp)| sp.policy).unwrap_or_else(|| policy.clone()); |
|
| 131 | + | if !signers.first().is_some_and(|(k, _)| trusted.roots.contains(k)) { |
|
| 132 | + | return fail("your key is not a root key of the current policy"); |
|
| 133 | + | } |
|
| 134 | + | let target = policy.hash(); |
|
| 135 | + | let sigs = signers.iter().map(|(k, sk)| sk.sign(Domain::Policy, k, &target)).collect(); |
|
| 136 | + | self.gather(SignedPolicy { policy, sigs }, &trusted, command) |
|
| 137 | + | } |
|
| 138 | + | ||
| 139 | + | /// Apply `sp` when its signatures meet `trusted`'s threshold. |
|
| 140 | + | fn gather(&self, sp: SignedPolicy, trusted: &Policy, command: &str) -> Result<PolicyUpdate> { |
|
| 141 | + | let applied = sp.policy.verify_signatures(&sp.sigs, trusted).is_ok(); |
|
| 142 | + | if applied { |
|
| 143 | + | self.set_policy(&sp, command)?; |
|
| 144 | + | } |
|
| 145 | + | Ok(PolicyUpdate { policy: sp.policy, sigs: sp.sigs, applied }) |
|
| 146 | + | } |
|
| 147 | + | ||
| 148 | + | /// Add your signature to a proposal made by another root holder; the |
|
| 149 | + | /// policy is applied once the current root threshold is met. |
|
| 150 | + | pub fn policy_sign(&self, proposal: SignedPolicy) -> Result<PolicyUpdate> { |
|
| 151 | + | let Some((key, sk)) = self.secret_key()? else { |
|
| 152 | + | return fail("no secret key: run `oxy identity create` first"); |
|
| 153 | + | }; |
|
| 154 | + | let Some((_, current)) = self.current_policy()? else { |
|
| 155 | + | return fail("this repository has no policy; run `oxy policy init` or fetch one"); |
|
| 156 | + | }; |
|
| 157 | + | if proposal.policy.repo != self.id()? { |
|
| 158 | + | return fail("the proposal is for a different repository"); |
|
| 62 | 159 | } |
|
| 63 | - | if policy.root_threshold > 1 { |
|
| 64 | - | return fail(format!("this policy needs {} root signatures; multi-signer updates are not implemented yet", policy.root_threshold)); |
|
| 160 | + | if proposal.policy.serial <= current.policy.serial { |
|
| 161 | + | return fail(format!("the proposal's serial {} is not greater than the current {}", proposal.policy.serial, current.policy.serial)); |
|
| 65 | 162 | } |
|
| 66 | - | let sig = sk.sign(Domain::Policy, &key, &policy.hash()); |
|
| 67 | - | Ok(SignedPolicy { policy, sigs: vec![sig] }) |
|
| 163 | + | if !current.policy.roots.contains(&key) { |
|
| 164 | + | return fail("your key is not a root key of the current policy"); |
|
| 165 | + | } |
|
| 166 | + | let mut sigs: Vec<Signature> = proposal.sigs.into_iter().filter(|s| s.key != key).collect(); |
|
| 167 | + | sigs.push(sk.sign(Domain::Policy, &key, &proposal.policy.hash())); |
|
| 168 | + | self.gather(SignedPolicy { policy: proposal.policy, sigs }, ¤t.policy, "oxy policy sign") |
|
| 68 | 169 | } |
|
| 69 | 170 | ||
| 70 | 171 | /// Create the initial policy with the configured identity as sole root. |
|
| 71 | - | pub fn policy_init(&self) -> Result<SignedPolicy> { |
|
| 172 | + | pub fn policy_init(&self) -> Result<PolicyUpdate> { |
|
| 72 | 173 | if self.current_policy()?.is_some() { |
|
| 73 | 174 | return fail("this repository already has a policy"); |
|
| 74 | 175 | } |
|
| … | 2 unchanged lines |
||
| 77 | 178 | }; |
|
| 78 | 179 | let identity = self.identity(&key)?; |
|
| 79 | 180 | let policy = Policy::single_root(self.id()?, identity, Hlc::now().wall_ms + POLICY_LIFETIME_MS); |
|
| 80 | - | let sp = self.sign_policy(policy)?; |
|
| 81 | - | self.set_policy(&sp, "oxy policy init")?; |
|
| 82 | - | Ok(sp) |
|
| 181 | + | self.sign_policy(policy, "oxy policy init") |
|
| 182 | + | } |
|
| 183 | + | ||
| 184 | + | /// Make `identity` a root key. |
|
| 185 | + | pub fn policy_root_add(&self, identity: Identity) -> Result<PolicyUpdate> { |
|
| 186 | + | let mut p = self.next_policy()?; |
|
| 187 | + | let key = identity.hash(); |
|
| 188 | + | if p.roots.contains(&key) { |
|
| 189 | + | return fail(format!("{} is already a root", key.short(12))); |
|
| 190 | + | } |
|
| 191 | + | p.roots.push(key); |
|
| 192 | + | p.roots.sort(); |
|
| 193 | + | p.revoked.retain(|k| k != &key); |
|
| 194 | + | if p.identity(&key).is_none() { |
|
| 195 | + | p.identities.push(identity); |
|
| 196 | + | } |
|
| 197 | + | self.sign_policy(p, &format!("oxy policy root add {}", key.short(12))) |
|
| 83 | 198 | } |
|
| 84 | 199 | ||
| 85 | - | fn next_policy(&self) -> Result<Policy> { |
|
| 200 | + | /// Drop a root key; the remaining roots must still meet the threshold. |
|
| 201 | + | pub fn policy_root_remove(&self, key: &KeyId) -> Result<PolicyUpdate> { |
|
| 202 | + | let mut p = self.next_policy()?; |
|
| 203 | + | if !p.roots.contains(key) { |
|
| 204 | + | return fail(format!("{} is not a root", key.short(12))); |
|
| 205 | + | } |
|
| 206 | + | p.roots.retain(|k| k != key); |
|
| 207 | + | if (p.roots.len() as u32) < p.root_threshold { |
|
| 208 | + | return fail(format!("removing it would leave {} root(s) for a threshold of {}", p.roots.len(), p.root_threshold)); |
|
| 209 | + | } |
|
| 210 | + | self.sign_policy(p, &format!("oxy policy root remove {}", key.short(12))) |
|
| 211 | + | } |
|
| 212 | + | ||
| 213 | + | /// Require `n` root signatures on future policy updates. |
|
| 214 | + | pub fn policy_set_threshold(&self, n: u32) -> Result<PolicyUpdate> { |
|
| 215 | + | let mut p = self.next_policy()?; |
|
| 216 | + | if n == 0 || n as usize > p.roots.len() { |
|
| 217 | + | return fail(format!("the threshold must be between 1 and the number of roots ({})", p.roots.len())); |
|
| 218 | + | } |
|
| 219 | + | p.root_threshold = n; |
|
| 220 | + | self.sign_policy(p, &format!("oxy policy root threshold {n}")) |
|
| 221 | + | } |
|
| 222 | + | ||
| 223 | + | pub(crate) fn next_policy(&self) -> Result<Policy> { |
|
| 86 | 224 | let Some((_, sp)) = self.current_policy()? else { |
|
| 87 | 225 | return fail("this repository has no policy; run `oxy policy init`"); |
|
| 88 | 226 | }; |
|
| … | 4 unchanged lines |
||
| 93 | 231 | } |
|
| 94 | 232 | ||
| 95 | 233 | /// Let `identity` update references matching `pattern` (default every reference). |
|
| 96 | - | pub fn policy_grant(&self, identity: Identity, pattern: Option<&str>) -> Result<SignedPolicy> { |
|
| 234 | + | pub fn policy_grant(&self, identity: Identity, pattern: Option<&str>) -> Result<PolicyUpdate> { |
|
| 97 | 235 | let mut p = self.next_policy()?; |
|
| 98 | 236 | let key = identity.hash(); |
|
| 99 | 237 | let pattern = pattern.unwrap_or("*"); |
|
| … | 10 unchanged lines |
||
| 110 | 248 | if p.identity(&key).is_none() { |
|
| 111 | 249 | p.identities.push(identity); |
|
| 112 | 250 | } |
|
| 113 | - | let sp = self.sign_policy(p)?; |
|
| 114 | - | self.set_policy(&sp, &format!("oxy policy grant {}", key.short(12)))?; |
|
| 115 | - | Ok(sp) |
|
| 251 | + | self.sign_policy(p, &format!("oxy policy grant {}", key.short(12))) |
|
| 116 | 252 | } |
|
| 117 | 253 | ||
| 118 | - | /// Revoke `key`: it may no longer update anything, and existing rules drop it. |
|
| 119 | - | pub fn policy_revoke(&self, key: &KeyId) -> Result<SignedPolicy> { |
|
| 254 | + | /// Revoke `key`: it may no longer update anything, and existing rules |
|
| 255 | + | /// drop it. A root key can be revoked by another root as long as the |
|
| 256 | + | /// remaining roots still meet the threshold. |
|
| 257 | + | pub fn policy_revoke(&self, key: &KeyId) -> Result<PolicyUpdate> { |
|
| 120 | 258 | let mut p = self.next_policy()?; |
|
| 121 | 259 | if p.roots.contains(key) { |
|
| 122 | - | return fail("cannot revoke a root key"); |
|
| 260 | + | let own = self.secret_key()?.map(|(k, _)| k); |
|
| 261 | + | if own == Some(*key) { |
|
| 262 | + | return fail("a root cannot revoke itself; have another root revoke it, or remove it with `oxy policy root remove` first"); |
|
| 263 | + | } |
|
| 264 | + | p.roots.retain(|k| k != key); |
|
| 265 | + | if (p.roots.len() as u32) < p.root_threshold { |
|
| 266 | + | return fail(format!("revoking it would leave {} root(s) for a threshold of {}", p.roots.len(), p.root_threshold)); |
|
| 267 | + | } |
|
| 123 | 268 | } |
|
| 124 | 269 | for r in &mut p.rules { |
|
| 125 | 270 | r.keys.retain(|k| k != key); |
|
| … | 3 unchanged lines |
||
| 129 | 274 | p.revoked.push(*key); |
|
| 130 | 275 | p.revoked.sort(); |
|
| 131 | 276 | } |
|
| 132 | - | let sp = self.sign_policy(p)?; |
|
| 133 | - | self.set_policy(&sp, &format!("oxy policy revoke {}", key.short(12)))?; |
|
| 134 | - | Ok(sp) |
|
| 277 | + | self.sign_policy(p, &format!("oxy policy revoke {}", key.short(12))) |
|
| 135 | 278 | } |
|
| 136 | 279 | ||
| 137 | 280 | /// Whether `change` may be part of `reference` under `policy`: it must be |
|
| 138 | 281 | /// authenticated (by its author or a delegate) with a key the matching |
|
| 139 | - | /// rule lists, using the authenticator kind the rule requires. |
|
| 282 | + | /// rule lists, using the authenticator kind the rule requires. A rule key |
|
| 283 | + | /// covers its rotation predecessors, and a delegate's signature counts |
|
| 284 | + | /// for the author it acts for. |
|
| 140 | 285 | pub fn change_allowed(&self, hash: &ChangeHash, policy: &Policy, reference: &str) -> std::result::Result<(), String> { |
|
| 141 | 286 | let rule = policy.rule_for(reference).ok_or_else(|| format!("no policy rule matches {reference:?}"))?; |
|
| 142 | 287 | match self.authenticate_change(hash).map_err(|e| e.to_string())? { |
|
| … | 1 unchanged lines |
||
| 144 | 289 | if policy.is_revoked(&key) { |
|
| 145 | 290 | return Err(format!("change {} is signed by revoked key {}", hash.short(12), key.short(12))); |
|
| 146 | 291 | } |
|
| 147 | - | if !rule.keys.contains(&key) { |
|
| 292 | + | let author = self.store.get_change(hash).map(|c| c.author).map_err(|e| e.to_string())?; |
|
| 293 | + | let principal = if author.is_zero() { key } else { author }; |
|
| 294 | + | let covered = rule.keys.iter().any(|k| policy.succeeds(k, &key) || policy.succeeds(k, &principal)); |
|
| 295 | + | if !covered { |
|
| 148 | 296 | return Err(format!("change {} is signed by {}, which may not update {reference:?}", hash.short(12), key.short(12))); |
|
| 149 | 297 | } |
|
| 150 | 298 | match (rule.require, sealed) { |
|
| … | 27 unchanged lines |
modified crates/oxy-core/src/repo/sync.rs +12 −10
| … | 56 unchanged lines |
||
| 57 | 57 | let attestation = latest.filter(|a| matches!(&a.attest.body, AttestBody::Ref { new_state, .. } if new_state == state)); |
|
| 58 | 58 | out.push(RemoteRef { name: name.clone(), state: *state, heads: self.store.get_state(state)?.heads, attestation, last_serial }); |
|
| 59 | 59 | } |
|
| 60 | - | Ok(RefsResponse { refs: out, policy: self.current_policy()?.map(|(_, sp)| sp), tags: self.tag_refs()? }) |
|
| 60 | + | let policy = self.current_policy()?.map(|(_, sp)| sp); |
|
| 61 | + | let history = self.policy_history()?.into_iter().filter(|sp| policy.as_ref().is_some_and(|p| sp.policy.serial < p.policy.serial)).collect(); |
|
| 62 | + | Ok(RefsResponse { refs: out, policy, tags: self.tag_refs()?, history }) |
|
| 61 | 63 | } |
|
| 62 | 64 | ||
| 63 | 65 | /// Every change reachable from `heads` that is in the store. |
|
| … | 293 unchanged lines |
||
| 357 | 359 | pub fn receive_push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>, tags: Vec<crate::protocol::TagRef>) -> Result<Vec<RefResult>> { |
|
| 358 | 360 | let mut results = Vec::new(); |
|
| 359 | 361 | if let Some(sp) = policy { |
|
| 360 | - | let current = self.current_policy()?.map(|(_, c)| c.policy); |
|
| 361 | - | match self.check_policy(&sp, current.as_ref()) { |
|
| 362 | + | match self.adopt_policy_chain(&bundle.policies, &sp, "push received") { |
|
| 362 | 363 | Ok(()) => { |
|
| 363 | - | self.set_policy(&sp, &format!("push received: policy serial {}", sp.policy.serial))?; |
|
| 364 | 364 | results.push(RefResult { name: "policy".into(), ok: true, reason: format!("serial {}", sp.policy.serial) }); |
|
| 365 | 365 | } |
|
| 366 | 366 | Err(e) => { |
|
| … | 232 unchanged lines |
||
| 599 | 599 | return fail(format!("{remote_name} presents a different policy with the same serial {}", l.serial)); |
|
| 600 | 600 | } |
|
| 601 | 601 | } |
|
| 602 | - | local => { |
|
| 603 | - | self.check_policy(sp, local.as_ref()).map_err(|e| RepoError::Msg(format!("policy from {remote_name}: {e}")))?; |
|
| 604 | - | self.set_policy(sp, &format!("oxy fetch {remote_name} (policy serial {})", sp.policy.serial))?; |
|
| 602 | + | _ => { |
|
| 603 | + | self.adopt_policy_chain(&resp.history, sp, &format!("oxy fetch {remote_name}")).map_err(|e| RepoError::Msg(format!("policy from {remote_name}: {e}")))?; |
|
| 605 | 604 | } |
|
| 606 | 605 | } |
|
| 607 | 606 | self.record_policy_serial(remote_name, sp.policy.serial)?; |
|
| … | 99 unchanged lines |
||
| 707 | 706 | let resp = remote.refs()?; |
|
| 708 | 707 | let bookmark = self.config.bookmark.clone(); |
|
| 709 | 708 | if let Some(sp) = &resp.policy { |
|
| 710 | - | let local = self.current_policy()?.map(|(_, sp)| sp.policy); |
|
| 711 | - | self.check_policy(sp, local.as_ref()).map_err(|e| RepoError::Msg(format!("policy from {remote_name}: {e}")))?; |
|
| 712 | - | self.set_policy(sp, &format!("oxy clone --depth (policy serial {})", sp.policy.serial))?; |
|
| 709 | + | self.adopt_policy_chain(&resp.history, sp, "oxy clone --depth").map_err(|e| RepoError::Msg(format!("policy from {remote_name}: {e}")))?; |
|
| 713 | 710 | self.record_policy_serial(remote_name, sp.policy.serial)?; |
|
| 714 | 711 | } |
|
| 715 | 712 | let Some(r) = resp.refs.into_iter().find(|r| r.name == bookmark) else { |
|
| … | 92 unchanged lines |
||
| 808 | 805 | } |
|
| 809 | 806 | let resp = remote.refs()?; |
|
| 810 | 807 | let local_policy = self.current_policy()?.map(|(_, sp)| sp); |
|
| 808 | + | let resp_policy_serial = resp.policy.as_ref().map(|p| p.policy.serial); |
|
| 811 | 809 | let policy_to_send = match (&local_policy, &resp.policy) { |
|
| 812 | 810 | (Some(l), Some(r)) if l.policy.serial > r.policy.serial => Some(l.clone()), |
|
| 813 | 811 | (Some(l), None) => Some(l.clone()), |
|
| … | 77 unchanged lines |
||
| 891 | 889 | } |
|
| 892 | 890 | bundle.objects.push((ObjectType::State, StateObject::new(t.heads.clone()).encode())); |
|
| 893 | 891 | } |
|
| 892 | + | if let Some(l) = &policy_to_send { |
|
| 893 | + | let remote_serial = resp_policy_serial; |
|
| 894 | + | bundle.policies = self.policy_history()?.into_iter().filter(|sp| remote_serial.is_none_or(|r| sp.policy.serial > r) && sp.policy.serial < l.policy.serial).collect(); |
|
| 895 | + | } |
|
| 894 | 896 | let results = remote.push(bundle, vec![RefUpdate { name: bookmark.clone(), old_state: remote_state, new_state, force, attestation }], policy_to_send, tags_to_send)?; |
|
| 895 | 897 | if let (Some(serial), true) = (sent_serial, results.iter().any(|r| r.ok && r.name == "policy")) { |
|
| 896 | 898 | self.record_policy_serial(remote_name, serial)?; |
|
| … | 66 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +126 −1
| … | 288 unchanged lines |
||
| 289 | 289 | repo.save_config().unwrap(); |
|
| 290 | 290 | write(root, "a.txt", b"a\nb\nc\n"); |
|
| 291 | 291 | let h2 = repo.commit("signed", &[]).unwrap(); |
|
| 292 | - | assert_eq!(repo.verify_change(&h2).unwrap(), SignatureStatus::Valid { key, name: "Ada".into(), email: "[email protected]".into(), sealed: false }); |
|
| 292 | + | assert_eq!(repo.verify_change(&h2).unwrap(), SignatureStatus::Valid { key, name: "Ada".into(), email: "[email protected]".into(), sealed: false, revoked: false }); |
|
| 293 | 293 | ||
| 294 | 294 | // A signature by a key that is not the author or a delegate is rejected. |
|
| 295 | 295 | let other = SecretKey::generate(); |
|
| … | 1300 unchanged lines |
||
| 1596 | 1596 | assert!(repo.split(&p2, &["nope.txt".into()]).is_err()); |
|
| 1597 | 1597 | let _ = h2; |
|
| 1598 | 1598 | } |
|
| 1599 | + | ||
| 1600 | + | #[test] |
|
| 1601 | + | fn identity_lifecycle() { |
|
| 1602 | + | hermetic(); |
|
| 1603 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 1604 | + | let root = dir.path(); |
|
| 1605 | + | let origin = Repo::init(&root.join("origin")).unwrap(); |
|
| 1606 | + | let url = root.join("origin").to_string_lossy().to_string(); |
|
| 1607 | + | let mut a = Repo::clone(&url, &root.join("a")).unwrap().with_home(root.join("home-a")); |
|
| 1608 | + | a.set_user_config("core.sign", "change").unwrap(); |
|
| 1609 | + | let (key_a, _) = a.create_identity("Ada", "[email protected]", IdentityScope::User).unwrap(); |
|
| 1610 | + | write(&root.join("a"), "a.txt", b"a\n"); |
|
| 1611 | + | let h1 = a.commit("one", &[]).unwrap(); |
|
| 1612 | + | assert!(a.policy_init().unwrap().applied); |
|
| 1613 | + | assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 1614 | + | ||
| 1615 | + | // Expiry: an identity that had expired when the change was made does not authenticate it. |
|
| 1616 | + | let mut e = Repo::init(&root.join("e")).unwrap().with_home(root.join("home-e")); |
|
| 1617 | + | e.set_user_config("core.sign", "change").unwrap(); |
|
| 1618 | + | e.create_identity_with("Eve", "[email protected]", IdentityScope::User, Some(Hlc::now().wall_ms - 1)).unwrap(); |
|
| 1619 | + | write(&root.join("e"), "e.txt", b"e\n"); |
|
| 1620 | + | let he = e.commit("late", &[]).unwrap(); |
|
| 1621 | + | assert!(matches!(e.verify_change(&he).unwrap(), SignatureStatus::Invalid(m) if m.contains("expired"))); |
|
| 1622 | + | // Renewal re-issues the identity (same keys, new KeyId) with a later expiry. |
|
| 1623 | + | let r = e.renew_identity(Some(Hlc::now().wall_ms + 86_400_000)).unwrap(); |
|
| 1624 | + | assert_eq!(e.identity(&r.new).unwrap().previous, Some(r.old)); |
|
| 1625 | + | write(&root.join("e"), "e.txt", b"e2\n"); |
|
| 1626 | + | let he2 = e.commit("in time", &[]).unwrap(); |
|
| 1627 | + | assert!(matches!(e.verify_change(&he2).unwrap(), SignatureStatus::Valid { key, .. } if key == r.new)); |
|
| 1628 | + | ||
| 1629 | + | // Rotation: a fresh key; the old change still verifies; the policy follows. |
|
| 1630 | + | let r = a.rotate_identity().unwrap(); |
|
| 1631 | + | assert_eq!(r.old, key_a); |
|
| 1632 | + | assert_eq!(a.config.author_key, Some(r.new)); |
|
| 1633 | + | assert_eq!(a.identity(&r.new).unwrap().previous, Some(key_a)); |
|
| 1634 | + | assert!(r.policy.as_ref().unwrap().applied); |
|
| 1635 | + | let (_, sp) = a.current_policy().unwrap().unwrap(); |
|
| 1636 | + | assert_eq!((sp.policy.serial, sp.policy.roots.clone()), (2, vec![r.new])); |
|
| 1637 | + | assert!(!sp.policy.revoked.contains(&key_a) && sp.policy.authorizes(&r.new, "main"), "superseded, not revoked"); |
|
| 1638 | + | assert!(sp.policy.succeeds(&r.new, &key_a) && !sp.policy.succeeds(&key_a, &r.new)); |
|
| 1639 | + | assert!(matches!(a.verify_change(&h1).unwrap(), SignatureStatus::Valid { key, revoked: false, .. } if key == key_a)); |
|
| 1640 | + | write(&root.join("a"), "a.txt", b"a2\n"); |
|
| 1641 | + | let h2 = a.commit("two", &[]).unwrap(); |
|
| 1642 | + | assert!(matches!(a.verify_change(&h2).unwrap(), SignatureStatus::Valid { key, revoked: false, .. } if key == r.new)); |
|
| 1643 | + | // Origin accepts the rotated policy (signed by the old root) and the new key's change. |
|
| 1644 | + | let res = a.push("origin", false).unwrap(); |
|
| 1645 | + | assert!(res.iter().all(|x| x.ok), "{res:?}"); |
|
| 1646 | + | assert_eq!(origin.current_policy().unwrap().unwrap().1.policy.serial, 2); |
|
| 1647 | + | let key_a = r.new; |
|
| 1648 | + | ||
| 1649 | + | // Delegation: Bob may sign on Ada's behalf. Delegates live in the |
|
| 1650 | + | // identity object, so Ada's KeyId changes while her keys stay the same. |
|
| 1651 | + | let mut b = Repo::clone(&url, &root.join("b")).unwrap().with_home(root.join("home-b")); |
|
| 1652 | + | let (key_b, id_b) = b.create_identity("Bob", "[email protected]", IdentityScope::User).unwrap(); |
|
| 1653 | + | let r = a.delegate_add(id_b.clone()).unwrap(); |
|
| 1654 | + | assert_eq!(r.old, key_a); |
|
| 1655 | + | let key_a2 = r.new; |
|
| 1656 | + | assert_eq!(a.identity(&key_a2).unwrap().delegates, vec![key_b]); |
|
| 1657 | + | assert_eq!(a.identity(&key_a2).unwrap().ed25519_pk, a.identity(&key_a).unwrap().ed25519_pk); |
|
| 1658 | + | assert!(a.delegate_add(id_b.clone()).is_err(), "already a delegate"); |
|
| 1659 | + | assert!(a.push("origin", false).unwrap().iter().all(|x| x.ok)); |
|
| 1660 | + | write(&root.join("a"), "a.txt", b"a3\n"); |
|
| 1661 | + | let h3 = a.commit("three", &[]).unwrap(); |
|
| 1662 | + | assert!(matches!(a.verify_change(&h3).unwrap(), SignatureStatus::Valid { key, .. } if key == key_a2), "the same secret signs under the new KeyId"); |
|
| 1663 | + | // A change authored by Ada but signed only by Bob verifies through the delegation. |
|
| 1664 | + | a.set_user_config("core.sign", "seal").unwrap(); |
|
| 1665 | + | write(&root.join("a"), "a.txt", b"a4\n"); |
|
| 1666 | + | let h4 = a.commit("four", &[]).unwrap(); |
|
| 1667 | + | assert_eq!(a.verify_change(&h4).unwrap(), SignatureStatus::Unsigned); |
|
| 1668 | + | let (_, sk_b) = b.secret_key().unwrap().unwrap(); |
|
| 1669 | + | a.store().put_signature(&h4, &sk_b.sign(Domain::Change, &key_b, &h4)).unwrap(); |
|
| 1670 | + | assert!(matches!(a.verify_change(&h4).unwrap(), SignatureStatus::Valid { key, .. } if key == key_b)); |
|
| 1671 | + | a.set_user_config("core.sign", "change").unwrap(); |
|
| 1672 | + | // Withdrawing the delegation re-issues the identity again. |
|
| 1673 | + | let r = a.delegate_remove(&key_b).unwrap(); |
|
| 1674 | + | assert!(a.identity(&r.new).unwrap().delegates.is_empty()); |
|
| 1675 | + | assert!(a.delegate_remove(&key_b).is_err()); |
|
| 1676 | + | let key_a3 = r.new; |
|
| 1677 | + | let res = a.push("origin", false).unwrap(); |
|
| 1678 | + | assert!(res.iter().all(|x| x.ok), "{res:?}"); |
|
| 1679 | + | ||
| 1680 | + | // Multi-signer policy: Bob becomes a root and the threshold rises to 2, |
|
| 1681 | + | // both applied under the old threshold of 1. A grant then needs Bob too. |
|
| 1682 | + | assert!(a.policy_root_add(id_b.clone()).unwrap().applied); |
|
| 1683 | + | assert!(a.policy_root_add(id_b.clone()).is_err()); |
|
| 1684 | + | assert!(a.policy_set_threshold(3).is_err()); |
|
| 1685 | + | assert!(a.policy_set_threshold(2).unwrap().applied); |
|
| 1686 | + | assert!(a.push("origin", false).unwrap().iter().all(|x| x.ok)); |
|
| 1687 | + | b.pull("origin").unwrap(); |
|
| 1688 | + | assert_eq!(b.current_policy().unwrap().unwrap().1.policy.root_threshold, 2); |
|
| 1689 | + | let mut c = Repo::init(&root.join("c")).unwrap().with_home(root.join("home-c")); |
|
| 1690 | + | let (key_c, id_c) = c.create_identity("Cy", "[email protected]", IdentityScope::User).unwrap(); |
|
| 1691 | + | let serial_before = a.current_policy().unwrap().unwrap().1.policy.serial; |
|
| 1692 | + | let proposal = a.policy_grant(id_c, None).unwrap(); |
|
| 1693 | + | assert!(!proposal.applied && proposal.sigs.len() == 1); |
|
| 1694 | + | assert_eq!(proposal.policy.serial, serial_before + 1); |
|
| 1695 | + | assert_eq!(a.current_policy().unwrap().unwrap().1.policy.serial, serial_before, "a proposal changes nothing yet"); |
|
| 1696 | + | // Bob signs the proposal, handed over as a file, and it applies for him. |
|
| 1697 | + | let bytes = proposal.signed().encode(); |
|
| 1698 | + | let signed = b.policy_sign(SignedPolicy::decode(&bytes).unwrap()).unwrap(); |
|
| 1699 | + | assert!(signed.applied && signed.sigs.len() == 2); |
|
| 1700 | + | assert!(b.current_policy().unwrap().unwrap().1.policy.authorizes(&key_c, "main")); |
|
| 1701 | + | assert!(b.policy_sign(signed.signed()).is_err(), "a stale proposal is refused"); |
|
| 1702 | + | // Bob publishes it and Ada picks it up. |
|
| 1703 | + | assert!(b.push("origin", false).unwrap().iter().any(|x| x.name == "policy" && x.ok)); |
|
| 1704 | + | a.pull("origin").unwrap(); |
|
| 1705 | + | assert_eq!(a.current_policy().unwrap().unwrap().1.policy.serial, serial_before + 1); |
|
| 1706 | + | // A root cannot revoke itself; revoking Cy needs both roots but is recorded locally at once. |
|
| 1707 | + | assert!(a.policy_revoke(&key_a3).is_err()); |
|
| 1708 | + | let u = a.revoke_identity(&key_c, "left the project").unwrap().unwrap(); |
|
| 1709 | + | assert!(!u.applied); |
|
| 1710 | + | assert!(a.is_revoked(&key_c).unwrap()); |
|
| 1711 | + | assert!(!b.is_revoked(&key_c).unwrap()); |
|
| 1712 | + | let u = b.policy_sign(u.signed()).unwrap(); |
|
| 1713 | + | assert!(u.applied && u.policy.is_revoked(&key_c)); |
|
| 1714 | + | assert!(b.is_revoked(&key_c).unwrap()); |
|
| 1715 | + | // Bob can revoke Ada's root key, leaving himself as the only root (threshold 2 would be unmeetable). |
|
| 1716 | + | assert!(b.policy_revoke(&key_a3).is_err(), "would leave one root for a threshold of 2"); |
|
| 1717 | + | // Revoking one's own key without a policy root works locally: a plain marker plus attestation. |
|
| 1718 | + | let key_c_own = c.config.author_key.unwrap(); |
|
| 1719 | + | assert!(c.revoke_identity(&key_c_own, "lost laptop").unwrap().is_none()); |
|
| 1720 | + | assert!(c.is_revoked(&key_c_own).unwrap()); |
|
| 1721 | + | assert_eq!(key_c_own, key_c); |
|
| 1722 | + | } |
|
| 1723 | + |
modified docs/plan.md +4 −2
| … | 15 unchanged lines |
||
| 16 | 16 | - [x] Pack merging (spec §13.4) — done 2026-09-27 00:05; folds packs into one above 16, also after auto-pack. merge packs when more than 16 exist; `oxy gc` reports it. |
|
| 17 | 17 | - [x] Modern CLI presentation (user request, 2026-09-26 23:20) — done 2026-09-27 00:05 via `src/cli/ui.rs` (console + indicatif): colour, spinners on long commands, `--plain`, NO_COLOR and non-TTY detection; status/log/show/commit/checkout/verify/sync/gc restyled. colour and bold for ids, paths, and statuses; a live spinner or updating status line while a command works (snapshot, fetch, push, import, gc); indented, grouped output like Claude Code's. Use `console`/`indicatif` or equivalent; detect a TTY and honour `NO_COLOR` and `--no-color`/`--plain` so pipes and scripts get plain text; keep `--json` where it exists. Touch every command's output for consistency. |
|
| 18 | 18 | - [x] Shell prompt integration (user request, 2026-09-26 23:30) — done 2026-09-27 00:05: `oxy prompt [--format]`, manifest-based dirty check (<10 ms), ahead/behind from tracking, `docs/prompt.md` with bash/oh-my-bash/zsh snippets. `oxy prompt` prints a compact one-line summary for shell prompts in a few milliseconds and with no side effects (no snapshot operation recorded, no locks): bookmark, working-copy ChangeId prefix, head count, a dirty marker (compare the file system against the working copy cheaply: sizes and mtimes first, content only when they differ), conflict marker, and ahead/behind counts against `remotes/origin/<bookmark>` when known. Provide `docs/prompt.md` with an oh-my-bash/bash snippet (`oxy_prompt_info` function, silent outside an oxy repository, colours via `--plain` off) and a zsh variant. Format flags: `--format` with placeholders, `--plain`. |
|
| 19 | - | - [ ] (Pending) The user's second request in the same message was cut off after "Can we also look at"; ask them to complete it in the morning. |
|
| 19 | + | - [ ] (Pending, for the morning) Ask Andy to complete his cut-off request ("Can we also look at ..."). (GPG question settled: Andy said to leave GPG out.) |
|
| 20 | 20 | - [x] Lazy blobs and the `objects` endpoint — done 2026-09-27 00:45: `clone --max-blob`, `filter.max-blob`, `.oxy/lazy/<ref>` registry, fetch on first read (checkout, export), server `objects` route; status/commit need no blobs. (spec §13.5, §16.1, §17): `clone --max-blob SIZE`, absent blobs recorded with their remote, fetched on first read; server endpoint; wire encoding in spec §16.3. |
|
| 21 | 21 | - [x] Sparse checkout (spec §17) — done 2026-09-27 01:05: `filter.paths`, `clone --sparse` (sets max-blob 0), `oxy sparse PATH..`, snapshot/checkout/prompt honour scope; out-of-scope blobs never fetched. `filter.paths` in config, `clone --sparse PATH..`, status/commit/checkout honour it. |
|
| 22 | 22 | - [x] Shallow clone (spec §17) — done 2026-09-27 01:50: `clone --depth N` takes the remote's checkpoint plus N levels of history, `.oxy/shallow` boundary, negotiation and imports tolerate it, `oxy deepen`; log works from checkpoint metadata. `clone --depth N`, shallow boundary with a checkpoint, `oxy deepen`. |
|
| … | 4 unchanged lines |
||
| 27 | 27 | - [x] Amend on non-head changes; `oxy split` — done 2026-09-27 04:45: `oxy amend --into CHANGE` appends the working copy's edits to an older change when they reference only its closure (identity correspondence for dependents, refused otherwise with a clear message); `oxy split CHANGE PATH..` rebuilds a head as two changes. Found and fixed a graph bug: a delete/insert conflict now requires the deleter and inserter to commute (both directions). |
|
| 28 | 28 | - [x] Reify orphaned references on rewrite as conflicts instead of refusing (spec §8.4); define the conflict shape in the spec. (Done: re-anchoring to the nearest surviving line, else the chain's anchors, so order conflicts arise naturally; vanished nodes drop dependent edits; notes reported by `oxy abandon`.) |
|
| 29 | 29 | - [x] User request (2026-09-27, mid-loop): make `oxy log` and all output look modern like `git log` with lines, spacing, and glyphs when the terminal supports them. (Done: rail-and-bullet log with `-n`/`-v`, relative times, authors, signature marks; status/show/op log/tags/workspaces/remotes/fetch/push/policy/identity restyled; ASCII fallback when piped or with `--plain`/`NO_COLOR`/non-UTF-8 locale.) |
|
| 30 | - | - [ ] Identity lifecycle: `oxy identity rotate|revoke`, expiry, delegates (`oxy identity delegate add|remove`), multi-signer policy updates. |
|
| 30 | + | - [x] Identity lifecycle: `oxy identity rotate|revoke`, expiry, delegates (`oxy identity delegate add|remove`), multi-signer policy updates. (Done: rotation as supersession with chain-aware policy authorization, renew, delegates, revocation attestations and local markers, expiry checks, root add/remove/threshold, proposal files and `oxy policy sign`; spec 0.6n.) |
|
| 31 | + | - [x] Push and serve the chain of policies since the receiver's serial, so several offline rotations or updates publish in one push or fetch. (Done in iteration 12: `.oxy/policies/` index, `refs` history, bundle policies.) |
|
| 31 | 32 | - [ ] Rename detection on Git import (similarity ≥ 50%); `oxy git fetch`/`push` against a Git remote via a mirror. |
|
| 32 | 33 | - [ ] Hardening: property tests for the CBOR decoder and pack reader against random and mutated inputs; a concurrent-push test against the server; a 1 GB single-file benchmark for chunking and blobs. |
|
| 33 | 34 | - [ ] Documentation: `docs/guide.md` (user guide by task) and `docs/protocol.md` (for other implementers, derived from spec §16 and the wire encodings). |
|
| … | 2 unchanged lines |
||
| 36 | 37 | ## Log |
|
| 37 | 38 | ||
| 38 | 39 | - 2026-09-26 23:01 plan created. |
|
| 40 | + | - 2026-09-27 06:30 iteration 12: identity lifecycle and multi-signer policy updates; policy chains on fetch and push (spec 0.6n). 106 tests. |
|
| 39 | 41 | - 2026-09-27 05:45 iteration 11: modern CLI output across all commands (user request). 105 tests. |
|
| 40 | 42 | - 2026-09-27 05:15 iteration 10: orphaned references reified on rewrite (spec 0.6m); gc fixed to tolerate old views whose working copies were pruned under a zero op-retention during development (this repo's oldest 20 operations). 104 tests. |
|
| 41 | 43 | - 2026-09-27 04:45 iteration 9: amend --into and split; graph delete/insert fix. 104 tests. |
|
| … | 8 unchanged lines |
modified docs/spec.md +13 −7
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | | | | |
|
| 4 | 4 | |---|---| |
|
| 5 | - | | **Status** | Draft 0.6m | |
|
| 5 | + | | **Status** | Draft 0.6n | |
|
| 6 | 6 | | **Format version** | 1 | |
|
| 7 | 7 | | **Date** | 2026-09-26 | |
|
| 8 | 8 | | **Audience** | Implementers, security reviewers, and contributors | |
|
| … | 550 unchanged lines |
||
| 559 | 559 | ||
| 560 | 560 | 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). |
|
| 561 | 561 | ||
| 562 | + | **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. |
|
| 563 | + | ||
| 562 | 564 | ### 14.3 Composite signature (G2, G4) |
|
| 563 | 565 | ||
| 564 | 566 | ``` |
|
| … | 55 unchanged lines |
||
| 620 | 622 | } |
|
| 621 | 623 | ``` |
|
| 622 | 624 | ||
| 623 | - | - The initial policy is signed by the root keys; every update MUST be signed by `roots.threshold` root keys and have a higher `serial`. |
|
| 625 | + | - 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. |
|
| 626 | + | - 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. |
|
| 624 | 627 | - 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`. |
|
| 625 | 628 | - 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. |
|
| 626 | 629 | - 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. |
|
| 627 | - | - **Propagation.** The `refs` response carries the current signed policy and a push MAY carry a newer one, applied before any bookmark update. A receiver accepts a policy only if it is for the same repository, its serial exceeds the current one, and it is signed by the current policy's root threshold; the first policy a repository ever sees vouches for itself. |
|
| 630 | + | - **Propagation.** The `refs` response carries the current signed policy together with the earlier policies the server still holds, and a push MAY carry a newer policy with the intermediate ones in its bundle, applied before any bookmark update. A receiver behind by several serials verifies each step against the one before it. A receiver accepts a policy only if it is for the same repository, its serial exceeds the current one, and it is signed by the current policy's root threshold; the first policy a repository ever sees vouches for itself, which means at least one valid signature by one of its own roots (trust on first use). |
|
| 628 | 631 | - **Downgrade protection.** A client records the highest policy serial each remote has presented. A remote that later presents none, or a lower serial, is refused. A remote that has never presented a policy is unverified: the client fetches with a warning and cannot verify what it received. Policy signatures use the domain `policy` (§14.3). |
|
| 629 | 632 | - **Existing history.** A policy governs changes added after it. The receiver of a push checks the changes the new state adds beyond the state it already had, so history that predates the policy is not re-verified. A fetching client verifies the bookmark's attestation, which binds the whole state through its head hashes, and does not re-verify individual changes; it therefore trusts what an authorized attester vouched for, which is the point of the policy. |
|
| 630 | 633 | ||
| … | 54 unchanged lines |
||
| 685 | 688 | | Message | Encoding | |
|
| 686 | 689 | |---|---| |
|
| 687 | 690 | | Signed attestation | `[attest bytes, [Signature]]` | |
|
| 688 | - | | `Bundle` | `{0: [[change bytes, [Signature]]], 1: [signed attestation], 2: [[type name, object bytes]]}`; changes in dependency order; objects are chunks, blobs, identities, and states only | |
|
| 691 | + | | `Bundle` | `{0: [[change bytes, [Signature]]], 1: [signed attestation], 2: [[type name, object bytes]], 3: [signed policy]}`; changes in dependency order; objects are chunks, blobs, identities, and states only; key 3 (optional) holds the policies between the receiver's serial and the one sent with a push, ascending | |
|
| 689 | 692 | | `capabilities` response | `{0: [versions], 1: repo id bytes(16), 2: [feature names]}` | |
|
| 690 | 693 | | Signed policy | `[policy bytes, [Signature]]` | |
|
| 691 | - | | `refs` response | `{0: [{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null}], 1: repo id, 2: signed policy or null, 3: [tag]}`; the attestation is present only when it describes the current state; a tag is `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null}` | |
|
| 694 | + | | `refs` response | `{0: [{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null}], 1: repo id, 2: signed policy or null, 3: [tag], 4: [signed policy]}`; key 4 (optional) lists earlier policies ascending by serial; the attestation is present only when it describes the current state; a tag is `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null}` | |
|
| 692 | 695 | | `negotiate` request | `{0: [have], 1: [want]}`; response `{0: [hashes]}` in dependency order | |
|
| 693 | 696 | | `fetch` request | `{0: [hashes], 1: max blob size or null}`; response a `Bundle` without blobs above the size | |
|
| 694 | 697 | | `objects` request | `{0: [refs]}`; response a `Bundle` carrying only objects, a blob accompanied by its chunks | |
|
| … | 101 unchanged lines |
||
| 796 | 799 | oxy fetch [REMOTE] oxy push [REMOTE] [-b BOOKMARK] |
|
| 797 | 800 | oxy undo oxy redo oxy op log |
|
| 798 | 801 | oxy lock PATH oxy unlock PATH |
|
| 799 | - | oxy identity create|rotate|revoke oxy policy edit|sign|show |
|
| 802 | + | oxy identity create|show|export|rotate|renew|revoke|delegate |
|
| 803 | + | oxy policy init|show|grant|revoke|root|sign |
|
| 800 | 804 | oxy remote add|trust|list oxy gc |
|
| 801 | 805 | oxy git import|export|fetch|push |
|
| 802 | 806 | ``` |
|
| … | 39 unchanged lines |
||
| 842 | 846 | ||
| 843 | 847 | --- |
|
| 844 | 848 | ||
| 845 | - | *Changelog.* Draft 0.6m (2026-09-27): orphaned references on rewrite are reified by re-anchoring rather than refused (§8.4). |
|
| 849 | + | *Changelog.* Draft 0.6n (2026-09-27): identity lifecycle (§14.2): every identity edit is a rotation that supersedes rather than revokes, expiry bounds authenticated changes, revocations recorded locally; policy proposals for multi-signer updates, chain-aware authorization, bootstrap rule, and policy history on `refs` and in push bundles (§14.5, §16.3). |
|
| 850 | + | ||
| 851 | + | Draft 0.6m (2026-09-27): orphaned references on rewrite are reified by re-anchoring rather than refused (§8.4). |
|
| 846 | 852 | ||
| 847 | 853 | Draft 0.6l (2026-09-27): amend into older changes and split (§8.4). |
|
| 848 | 854 | ||
| … | 39 unchanged lines |
modified src/cli/format.rs +3 −2
| … | 42 unchanged lines |
||
| 43 | 43 | pub fn signature_line(repo: &Repo, h: &ChangeHash) -> Result<String> { |
|
| 44 | 44 | Ok(match repo.authenticate_change(h)? { |
|
| 45 | 45 | SignatureStatus::Unsigned => "unsigned".to_string(), |
|
| 46 | - | SignatureStatus::Valid { key, name, email, sealed } => { |
|
| 47 | - | format!("{} by {name} <{email}> key {}", if sealed { "sealed" } else { "signed" }, key.short(12)) |
|
| 46 | + | SignatureStatus::Valid { key, name, email, sealed, revoked } => { |
|
| 47 | + | format!("{} by {name} <{email}> key {}{}", if sealed { "sealed" } else { "signed" }, key.short(12), if revoked { " (KEY REVOKED)" } else { "" }) |
|
| 48 | 48 | } |
|
| 49 | 49 | SignatureStatus::Invalid(why) => format!("INVALID signature: {why}"), |
|
| 50 | 50 | }) |
|
| … | 3 unchanged lines |
||
| 54 | 54 | pub fn signature_mark(repo: &Repo, h: &ChangeHash) -> Result<String> { |
|
| 55 | 55 | Ok(match repo.authenticate_change(h)? { |
|
| 56 | 56 | SignatureStatus::Unsigned => ui::dim(format!("{} unsigned", ui::glyph("·", "-"))), |
|
| 57 | + | SignatureStatus::Valid { sealed, revoked: true, .. } => ui::warn(format!("{} {} (key revoked)", ui::glyph("⚠", "!"), if sealed { "sealed" } else { "signed" })), |
|
| 57 | 58 | SignatureStatus::Valid { sealed, .. } => ui::ok(format!("{} {}", ui::glyph("✔", "v"), if sealed { "sealed" } else { "signed" })), |
|
| 58 | 59 | SignatureStatus::Invalid(why) => ui::err(format!("{} INVALID: {why}", ui::glyph("✖", "x"))), |
|
| 59 | 60 | }) |
|
| … | 47 unchanged lines |
modified src/cli/identity.rs +204 −18
| … | 5 unchanged lines |
||
| 6 | 6 | use anyhow::Result; |
|
| 7 | 7 | use oxy_core::hash::ChangeHash; |
|
| 8 | 8 | use oxy_core::identity::Identity; |
|
| 9 | - | use oxy_core::repo::{self, IdentityScope}; |
|
| 9 | + | use oxy_core::hash::KeyId; |
|
| 10 | + | use oxy_core::objects::Hlc; |
|
| 11 | + | use oxy_core::protocol::SignedPolicy; |
|
| 12 | + | use oxy_core::repo::{self, IdentityScope, PolicyUpdate, Repo, Rotation}; |
|
| 10 | 13 | use std::path::{Path, PathBuf}; |
|
| 11 | 14 | ||
| 12 | - | pub fn create(name: Option<String>, email: Option<String>, local: bool) -> Result<i32> { |
|
| 15 | + | const DAY_MS: u64 = 24 * 60 * 60 * 1000; |
|
| 16 | + | ||
| 17 | + | fn expiry_from_days(days: u64) -> Option<u64> { |
|
| 18 | + | (days > 0).then(|| Hlc::now().wall_ms + days * DAY_MS) |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | /// Resolve a KeyId prefix among the identities the repository knows. |
|
| 22 | + | fn resolve_key(repo: &Repo, prefix: &str) -> Result<KeyId> { |
|
| 23 | + | if let Some(k) = KeyId::from_hex(prefix) { |
|
| 24 | + | return Ok(k); |
|
| 25 | + | } |
|
| 26 | + | let mut candidates: Vec<KeyId> = Vec::new(); |
|
| 27 | + | if let Some((_, sp)) = repo.current_policy()? { |
|
| 28 | + | candidates.extend(sp.policy.identities.iter().map(|i| i.hash())); |
|
| 29 | + | candidates.extend(sp.policy.revoked.iter().copied()); |
|
| 30 | + | } |
|
| 31 | + | if let Some(own) = repo.config.author_key { |
|
| 32 | + | candidates.push(own); |
|
| 33 | + | if let Ok(id) = repo.identity(&own) { |
|
| 34 | + | candidates.extend(id.delegates.iter().copied()); |
|
| 35 | + | } |
|
| 36 | + | } |
|
| 37 | + | candidates.sort(); |
|
| 38 | + | candidates.dedup(); |
|
| 39 | + | let matches: Vec<_> = candidates.into_iter().filter(|k| k.starts_with_hex(prefix)).collect(); |
|
| 40 | + | match matches.len() { |
|
| 41 | + | 1 => Ok(matches[0]), |
|
| 42 | + | 0 => anyhow::bail!("no known key starts with {prefix:?}"), |
|
| 43 | + | _ => anyhow::bail!("{prefix:?} is ambiguous"), |
|
| 44 | + | } |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | /// Report a policy update: applied, or written out as a proposal that |
|
| 48 | + | /// other root holders sign with `oxy policy sign`. |
|
| 49 | + | fn report_update(u: &PolicyUpdate, out: Option<PathBuf>) -> Result<()> { |
|
| 50 | + | if u.applied { |
|
| 51 | + | println!("{} policy serial {} {}", ui::done("Applied"), u.policy.serial, ui::dim("(push to publish it)")); |
|
| 52 | + | return Ok(()); |
|
| 53 | + | } |
|
| 54 | + | let path = out.unwrap_or_else(|| PathBuf::from(format!("policy-{}.proposal", u.policy.serial))); |
|
| 55 | + | std::fs::write(&path, u.signed().encode())?; |
|
| 56 | + | println!( |
|
| 57 | + | "{} policy serial {} needs {} of {} root signatures; has {}. Wrote {} for another root to run {}", |
|
| 58 | + | ui::caution(), |
|
| 59 | + | u.policy.serial, |
|
| 60 | + | u.policy.root_threshold, |
|
| 61 | + | u.policy.roots.len(), |
|
| 62 | + | u.sigs.len(), |
|
| 63 | + | ui::path(path.display()), |
|
| 64 | + | ui::dim(format!("oxy policy sign {}", path.display())) |
|
| 65 | + | ); |
|
| 66 | + | Ok(()) |
|
| 67 | + | } |
|
| 68 | + | ||
| 69 | + | fn report_rotation(r: &Rotation, verb: &str) -> Result<()> { |
|
| 70 | + | println!("{} identity {} <{}>", ui::done(verb), ui::author(&r.identity.name), r.identity.email); |
|
| 71 | + | println!("{}", ui::kv("old key", ui::dim(r.old.to_hex()))); |
|
| 72 | + | println!("{}", ui::kv("new key", r.new.to_hex())); |
|
| 73 | + | if !r.identity.delegates.is_empty() { |
|
| 74 | + | println!("{}", ui::kv("delegates", r.identity.delegates.iter().map(|d| d.short(12)).collect::<Vec<_>>().join(" "))); |
|
| 75 | + | } |
|
| 76 | + | if let Some(e) = r.identity.expires { |
|
| 77 | + | println!("{}", ui::kv("expires", when(e))); |
|
| 78 | + | } |
|
| 79 | + | match &r.policy { |
|
| 80 | + | Some(u) => report_update(u, None)?, |
|
| 81 | + | None => println!(" {} {}", ui::note(), ui::dim("no policy names the old key; export the new identity for policy holders")), |
|
| 82 | + | } |
|
| 83 | + | Ok(()) |
|
| 84 | + | } |
|
| 85 | + | ||
| 86 | + | pub fn create(name: Option<String>, email: Option<String>, local: bool, expires: Option<u64>) -> Result<i32> { |
|
| 13 | 87 | let mut repo = open()?; |
|
| 14 | 88 | let name = name.unwrap_or_else(|| repo.config.author_name.clone()); |
|
| 15 | 89 | let email = email.unwrap_or_else(|| repo.config.author_email.clone()); |
|
| 16 | 90 | let scope = if local { IdentityScope::Repo } else { IdentityScope::User }; |
|
| 17 | - | let (key, _) = repo.create_identity(&name, &email, scope)?; |
|
| 91 | + | let (key, _) = repo.create_identity_with(&name, &email, scope, expires.and_then(expiry_from_days))?; |
|
| 18 | 92 | println!("{} identity {} <{email}>", ui::done("Created"), ui::author(&name)); |
|
| 19 | 93 | println!("{}", ui::kv("key", key)); |
|
| 20 | 94 | println!("{}", ui::kv("secret", ui::path(repo::default_keys_dir().display()))); |
|
| … | 17 unchanged lines |
||
| 38 | 112 | println!("{} {} <{}>", ui::section("identity"), ui::author(&id.name), id.email); |
|
| 39 | 113 | println!("{}", ui::kv("key", key)); |
|
| 40 | 114 | println!("{}", ui::kv("created", format!("{} {}", when(id.created), ui::dim(format!("({})", ui::ago(id.created)))))); |
|
| 115 | + | if let Some(e) = id.expires { |
|
| 116 | + | let expired = id.is_expired_at(Hlc::now().wall_ms); |
|
| 117 | + | println!("{}", ui::kv("expires", if expired { ui::err(format!("{} EXPIRED", when(e))) } else { when(e) })); |
|
| 118 | + | } |
|
| 119 | + | if let Some(p) = id.previous { |
|
| 120 | + | println!("{}", ui::kv("previous", ui::dim(p.short(12)))); |
|
| 121 | + | } |
|
| 122 | + | if !id.delegates.is_empty() { |
|
| 123 | + | println!("{}", ui::kv("delegates", id.delegates.iter().map(|d| d.short(12)).collect::<Vec<_>>().join(" "))); |
|
| 124 | + | } |
|
| 125 | + | if repo.is_revoked(&key)? { |
|
| 126 | + | println!("{}", ui::kv("status", ui::err(format!("{} REVOKED", ui::glyph("✖", "x"))))); |
|
| 127 | + | } |
|
| 41 | 128 | println!("{}", ui::kv("secret", if repo.secret_key()?.is_some() { ui::ok(format!("{} available", ui::glyph("✔", "*"))) } else { ui::err(format!("{} not found in the key directory", ui::glyph("✖", "x"))) })); |
|
| 42 | 129 | println!("{}", ui::kv("signing", if repo.config.sign_changes { "every change (core.sign = change)" } else { "at push via seals (core.sign = seal)" })); |
|
| 43 | 130 | Ok(0) |
|
| … | 51 unchanged lines |
||
| 95 | 182 | Ok(0) |
|
| 96 | 183 | } |
|
| 97 | 184 | ||
| 185 | + | pub fn rotate() -> Result<i32> { |
|
| 186 | + | let mut repo = open()?; |
|
| 187 | + | let r = repo.rotate_identity()?; |
|
| 188 | + | report_rotation(&r, "Rotated")?; |
|
| 189 | + | Ok(0) |
|
| 190 | + | } |
|
| 191 | + | ||
| 192 | + | pub fn renew(expires: u64) -> Result<i32> { |
|
| 193 | + | let mut repo = open()?; |
|
| 194 | + | let r = repo.renew_identity(expiry_from_days(expires))?; |
|
| 195 | + | report_rotation(&r, "Renewed")?; |
|
| 196 | + | Ok(0) |
|
| 197 | + | } |
|
| 198 | + | ||
| 199 | + | pub fn revoke(key: Option<&str>, reason: &str) -> Result<i32> { |
|
| 200 | + | let repo = open()?; |
|
| 201 | + | let key = match key { |
|
| 202 | + | Some(k) => resolve_key(&repo, k)?, |
|
| 203 | + | None => repo.config.author_key.ok_or_else(|| anyhow::anyhow!("no identity configured"))?, |
|
| 204 | + | }; |
|
| 205 | + | let update = repo.revoke_identity(&key, reason)?; |
|
| 206 | + | println!("{} key {}{}", ui::done("Revoked"), ui::hash(key.short(12)), if reason.is_empty() { String::new() } else { format!(": {reason}") }); |
|
| 207 | + | match update { |
|
| 208 | + | Some(u) => report_update(&u, None)?, |
|
| 209 | + | None => println!(" {} {}", ui::note(), ui::dim("recorded locally; no policy named the key")), |
|
| 210 | + | } |
|
| 211 | + | if repo.config.author_key == Some(key) { |
|
| 212 | + | println!(" {} {}", ui::caution(), ui::warn("this was your configured key; run oxy identity create or rotate")); |
|
| 213 | + | } |
|
| 214 | + | Ok(0) |
|
| 215 | + | } |
|
| 216 | + | ||
| 217 | + | pub fn delegate_add(identity_file: &Path) -> Result<i32> { |
|
| 218 | + | let mut repo = open()?; |
|
| 219 | + | let id = Identity::decode(&std::fs::read(identity_file)?)?; |
|
| 220 | + | let r = repo.delegate_add(id)?; |
|
| 221 | + | report_rotation(&r, "Delegated")?; |
|
| 222 | + | Ok(0) |
|
| 223 | + | } |
|
| 224 | + | ||
| 225 | + | pub fn delegate_remove(key: &str) -> Result<i32> { |
|
| 226 | + | let mut repo = open()?; |
|
| 227 | + | let k = resolve_key(&repo, key)?; |
|
| 228 | + | let r = repo.delegate_remove(&k)?; |
|
| 229 | + | report_rotation(&r, "Undelegated")?; |
|
| 230 | + | Ok(0) |
|
| 231 | + | } |
|
| 232 | + | ||
| 233 | + | pub fn delegate_list() -> Result<i32> { |
|
| 234 | + | let repo = open()?; |
|
| 235 | + | let Some(key) = repo.config.author_key else { |
|
| 236 | + | println!("No identity configured; run `oxy identity create`."); |
|
| 237 | + | return Ok(0); |
|
| 238 | + | }; |
|
| 239 | + | let id = repo.identity(&key)?; |
|
| 240 | + | if id.delegates.is_empty() { |
|
| 241 | + | println!("{}", ui::dim("No delegates.")); |
|
| 242 | + | } |
|
| 243 | + | for d in &id.delegates { |
|
| 244 | + | let who = repo.identity(d).map(|i| format!("{} <{}>", i.name, i.email)).unwrap_or_else(|_| "(unknown identity)".into()); |
|
| 245 | + | println!(" {} {} {}", ui::ok(ui::glyph("🔑", "*")), ui::hash(d.short(12)), ui::author(who)); |
|
| 246 | + | } |
|
| 247 | + | Ok(0) |
|
| 248 | + | } |
|
| 249 | + | ||
| 250 | + | pub fn policy_root_add(identity_file: &Path, out: Option<PathBuf>) -> Result<i32> { |
|
| 251 | + | let repo = open()?; |
|
| 252 | + | let id = Identity::decode(&std::fs::read(identity_file)?)?; |
|
| 253 | + | let key = id.hash(); |
|
| 254 | + | let u = repo.policy_root_add(id.clone())?; |
|
| 255 | + | println!("{} root {} <{}> ({})", ui::done("Added"), ui::author(&id.name), id.email, key.short(12)); |
|
| 256 | + | report_update(&u, out)?; |
|
| 257 | + | Ok(0) |
|
| 258 | + | } |
|
| 259 | + | ||
| 260 | + | pub fn policy_root_remove(key: &str, out: Option<PathBuf>) -> Result<i32> { |
|
| 261 | + | let repo = open()?; |
|
| 262 | + | let k = resolve_key(&repo, key)?; |
|
| 263 | + | let u = repo.policy_root_remove(&k)?; |
|
| 264 | + | println!("{} root {}", ui::done("Removed"), ui::hash(k.short(12))); |
|
| 265 | + | report_update(&u, out)?; |
|
| 266 | + | Ok(0) |
|
| 267 | + | } |
|
| 268 | + | ||
| 269 | + | pub fn policy_threshold(n: u32, out: Option<PathBuf>) -> Result<i32> { |
|
| 270 | + | let repo = open()?; |
|
| 271 | + | let u = repo.policy_set_threshold(n)?; |
|
| 272 | + | println!("{} root threshold to {n}", ui::done("Set")); |
|
| 273 | + | report_update(&u, out)?; |
|
| 274 | + | Ok(0) |
|
| 275 | + | } |
|
| 276 | + | ||
| 277 | + | pub fn policy_sign(proposal: &Path, out: Option<PathBuf>) -> Result<i32> { |
|
| 278 | + | let repo = open()?; |
|
| 279 | + | let sp = SignedPolicy::decode(&std::fs::read(proposal)?)?; |
|
| 280 | + | let u = repo.policy_sign(sp)?; |
|
| 281 | + | println!("{} policy serial {}", ui::done("Signed"), u.policy.serial); |
|
| 282 | + | report_update(&u, Some(out.unwrap_or_else(|| proposal.to_path_buf())))?; |
|
| 283 | + | Ok(0) |
|
| 284 | + | } |
|
| 285 | + | ||
| 98 | 286 | pub fn policy_init() -> Result<i32> { |
|
| 99 | 287 | let repo = open()?; |
|
| 100 | 288 | let sp = repo.policy_init()?; |
|
| 101 | - | println!("{} policy serial {} with root key {} {}", ui::done("Created"), sp.policy.serial, sp.policy.roots[0].short(12), ui::dim("(push to publish it)")); |
|
| 289 | + | println!("{} policy with root key {}", ui::done("Created"), sp.policy.roots[0].short(12)); |
|
| 290 | + | report_update(&sp, None)?; |
|
| 102 | 291 | Ok(0) |
|
| 103 | 292 | } |
|
| 104 | 293 | ||
| … | 27 unchanged lines |
||
| 132 | 321 | Ok(0) |
|
| 133 | 322 | } |
|
| 134 | 323 | ||
| 135 | - | pub fn policy_grant(identity_file: &Path, pattern: Option<&str>) -> Result<i32> { |
|
| 324 | + | pub fn policy_grant(identity_file: &Path, pattern: Option<&str>, out: Option<PathBuf>) -> Result<i32> { |
|
| 136 | 325 | let repo = open()?; |
|
| 137 | 326 | let id = Identity::decode(&std::fs::read(identity_file)?)?; |
|
| 138 | 327 | let key = id.hash(); |
|
| 139 | - | let sp = repo.policy_grant(id.clone(), pattern)?; |
|
| 140 | - | println!("{} {} <{}> ({}) on {} {}", ui::done("Granted"), ui::author(&id.name), id.email, key.short(12), ui::bookmark(pattern.unwrap_or("*")), ui::dim(format!("(policy serial {}; push to publish)", sp.policy.serial))); |
|
| 328 | + | let u = repo.policy_grant(id.clone(), pattern)?; |
|
| 329 | + | println!("{} {} <{}> ({}) on {}", ui::done("Granted"), ui::author(&id.name), id.email, key.short(12), ui::bookmark(pattern.unwrap_or("*"))); |
|
| 330 | + | report_update(&u, out)?; |
|
| 141 | 331 | Ok(0) |
|
| 142 | 332 | } |
|
| 143 | 333 | ||
| 144 | - | pub fn policy_revoke(key: &str) -> Result<i32> { |
|
| 334 | + | pub fn policy_revoke(key: &str, out: Option<PathBuf>) -> Result<i32> { |
|
| 145 | 335 | let repo = open()?; |
|
| 146 | - | let Some((_, sp)) = repo.current_policy()? else { |
|
| 336 | + | if repo.current_policy()?.is_none() { |
|
| 147 | 337 | anyhow::bail!("this repository has no policy"); |
|
| 148 | - | }; |
|
| 149 | - | let matches: Vec<_> = sp.policy.identities.iter().map(|i| i.hash()).filter(|k| k.starts_with_hex(key)).collect(); |
|
| 150 | - | let full = match matches.len() { |
|
| 151 | - | 1 => matches[0], |
|
| 152 | - | 0 => anyhow::bail!("no key in the policy starts with {key:?}"), |
|
| 153 | - | _ => anyhow::bail!("{key:?} is ambiguous"), |
|
| 154 | - | }; |
|
| 155 | - | let sp = repo.policy_revoke(&full)?; |
|
| 156 | - | println!("{} {} {}", ui::done("Revoked"), ui::hash(full.short(12)), ui::dim(format!("(policy serial {}; push to publish)", sp.policy.serial))); |
|
| 338 | + | } |
|
| 339 | + | let full = resolve_key(&repo, key)?; |
|
| 340 | + | let u = repo.policy_revoke(&full)?; |
|
| 341 | + | println!("{} {}", ui::done("Revoked"), ui::hash(full.short(12))); |
|
| 342 | + | report_update(&u, out)?; |
|
| 157 | 343 | Ok(0) |
|
| 158 | 344 | } |
modified src/cli/mod.rs +92 −4
| … | 240 unchanged lines |
||
| 241 | 241 | /// Record the author in this repository only, not in the user config |
|
| 242 | 242 | #[arg(long)] |
|
| 243 | 243 | local: bool, |
|
| 244 | + | /// Expire the identity this many days from now |
|
| 245 | + | #[arg(long)] |
|
| 246 | + | expires: Option<u64>, |
|
| 244 | 247 | }, |
|
| 245 | 248 | /// Show the configured identity |
|
| 246 | 249 | Show, |
|
| 247 | 250 | /// Write your identity object to a file, to hand to a policy holder |
|
| 248 | 251 | Export { path: Option<PathBuf> }, |
|
| 252 | + | /// Move to a fresh key pair; the policy follows if it names your key |
|
| 253 | + | Rotate, |
|
| 254 | + | /// Re-issue your identity with a new expiry (in days from now; 0 = never) |
|
| 255 | + | Renew { |
|
| 256 | + | #[arg(long)] |
|
| 257 | + | expires: u64, |
|
| 258 | + | }, |
|
| 259 | + | /// Revoke a key: yours by default, or any key if you hold a policy root |
|
| 260 | + | Revoke { |
|
| 261 | + | /// KeyId prefix (default: your own key) |
|
| 262 | + | key: Option<String>, |
|
| 263 | + | #[arg(long, default_value = "")] |
|
| 264 | + | reason: String, |
|
| 265 | + | }, |
|
| 266 | + | /// Keys allowed to sign or seal on your behalf |
|
| 267 | + | Delegate { |
|
| 268 | + | #[command(subcommand)] |
|
| 269 | + | cmd: DelegateCmd, |
|
| 270 | + | }, |
|
| 271 | + | } |
|
| 272 | + | ||
| 273 | + | #[derive(Subcommand)] |
|
| 274 | + | enum DelegateCmd { |
|
| 275 | + | /// Add a delegate from an exported identity file |
|
| 276 | + | Add { identity_file: PathBuf }, |
|
| 277 | + | /// Remove a delegate by KeyId prefix |
|
| 278 | + | Remove { key: String }, |
|
| 279 | + | /// List delegates |
|
| 280 | + | List, |
|
| 249 | 281 | } |
|
| 250 | 282 | ||
| 251 | 283 | #[derive(Subcommand)] |
|
| … | 21 unchanged lines |
||
| 273 | 305 | /// Reference pattern the grant applies to (default: every bookmark) |
|
| 274 | 306 | #[arg(long)] |
|
| 275 | 307 | pattern: Option<String>, |
|
| 308 | + | /// Where to write the proposal when more root signatures are needed |
|
| 309 | + | #[arg(long)] |
|
| 310 | + | out: Option<PathBuf>, |
|
| 276 | 311 | }, |
|
| 277 | 312 | /// Revoke a key (KeyId prefix of an identity in the policy) |
|
| 278 | - | Revoke { key: String }, |
|
| 313 | + | Revoke { |
|
| 314 | + | key: String, |
|
| 315 | + | /// Where to write the proposal when more root signatures are needed |
|
| 316 | + | #[arg(long)] |
|
| 317 | + | out: Option<PathBuf>, |
|
| 318 | + | }, |
|
| 319 | + | /// Manage root keys and the signature threshold for policy updates |
|
| 320 | + | Root { |
|
| 321 | + | #[command(subcommand)] |
|
| 322 | + | cmd: RootCmd, |
|
| 323 | + | }, |
|
| 324 | + | /// Add your root signature to a proposal file; applies it once the threshold is met |
|
| 325 | + | Sign { |
|
| 326 | + | proposal: PathBuf, |
|
| 327 | + | /// Where to write the proposal if it still needs signatures (default: in place) |
|
| 328 | + | #[arg(long)] |
|
| 329 | + | out: Option<PathBuf>, |
|
| 330 | + | }, |
|
| 331 | + | } |
|
| 332 | + | ||
| 333 | + | #[derive(Subcommand)] |
|
| 334 | + | enum RootCmd { |
|
| 335 | + | /// Make an exported identity a root key |
|
| 336 | + | Add { |
|
| 337 | + | identity_file: PathBuf, |
|
| 338 | + | #[arg(long)] |
|
| 339 | + | out: Option<PathBuf>, |
|
| 340 | + | }, |
|
| 341 | + | /// Remove a root key by KeyId prefix |
|
| 342 | + | Remove { |
|
| 343 | + | key: String, |
|
| 344 | + | #[arg(long)] |
|
| 345 | + | out: Option<PathBuf>, |
|
| 346 | + | }, |
|
| 347 | + | /// Require this many root signatures on policy updates |
|
| 348 | + | Threshold { |
|
| 349 | + | n: u32, |
|
| 350 | + | #[arg(long)] |
|
| 351 | + | out: Option<PathBuf>, |
|
| 352 | + | }, |
|
| 279 | 353 | } |
|
| 280 | 354 | ||
| 281 | 355 | /// Open the repository containing the current directory. |
|
| … | 47 unchanged lines |
||
| 329 | 403 | Cmd::Push { remote, force } => sync::push(remote.as_deref(), force), |
|
| 330 | 404 | Cmd::Serve { root, bind, tokens, tls_cert, tls_key } => sync::serve(root, &bind, tokens, tls_cert.zip(tls_key)), |
|
| 331 | 405 | Cmd::Identity { cmd } => match cmd { |
|
| 332 | - | IdentityCmd::Create { name, email, local } => identity::create(name, email, local), |
|
| 406 | + | IdentityCmd::Create { name, email, local, expires } => identity::create(name, email, local, expires), |
|
| 333 | 407 | IdentityCmd::Show => identity::show(), |
|
| 334 | 408 | IdentityCmd::Export { path } => identity::export(path), |
|
| 409 | + | IdentityCmd::Rotate => identity::rotate(), |
|
| 410 | + | IdentityCmd::Renew { expires } => identity::renew(expires), |
|
| 411 | + | IdentityCmd::Revoke { key, reason } => identity::revoke(key.as_deref(), &reason), |
|
| 412 | + | IdentityCmd::Delegate { cmd } => match cmd { |
|
| 413 | + | DelegateCmd::Add { identity_file } => identity::delegate_add(&identity_file), |
|
| 414 | + | DelegateCmd::Remove { key } => identity::delegate_remove(&key), |
|
| 415 | + | DelegateCmd::List => identity::delegate_list(), |
|
| 416 | + | }, |
|
| 335 | 417 | }, |
|
| 336 | 418 | Cmd::Git { cmd: GitCmd::Export { git_dir } } => sync::git_export(&git_dir), |
|
| 337 | 419 | Cmd::Git { cmd: GitCmd::Import { git_dir, dest, branch } } => sync::git_import(&git_dir, &dest, &branch), |
|
| 338 | 420 | Cmd::Policy { cmd } => match cmd { |
|
| 339 | 421 | PolicyCmd::Init => identity::policy_init(), |
|
| 340 | 422 | PolicyCmd::Show => identity::policy_show(), |
|
| 341 | - | PolicyCmd::Grant { identity_file, pattern } => identity::policy_grant(&identity_file, pattern.as_deref()), |
|
| 342 | - | PolicyCmd::Revoke { key } => identity::policy_revoke(&key), |
|
| 423 | + | PolicyCmd::Grant { identity_file, pattern, out } => identity::policy_grant(&identity_file, pattern.as_deref(), out), |
|
| 424 | + | PolicyCmd::Revoke { key, out } => identity::policy_revoke(&key, out), |
|
| 425 | + | PolicyCmd::Root { cmd } => match cmd { |
|
| 426 | + | RootCmd::Add { identity_file, out } => identity::policy_root_add(&identity_file, out), |
|
| 427 | + | RootCmd::Remove { key, out } => identity::policy_root_remove(&key, out), |
|
| 428 | + | RootCmd::Threshold { n, out } => identity::policy_threshold(n, out), |
|
| 429 | + | }, |
|
| 430 | + | PolicyCmd::Sign { proposal, out } => identity::policy_sign(&proposal, out), |
|
| 343 | 431 | }, |
|
| 344 | 432 | Cmd::Verify { change } => identity::verify(change.as_deref()), |
|
| 345 | 433 | Cmd::Config { key, value, user } => identity::config(&key, &value, user), |
|
| … | 2 unchanged lines |