OXYGENHUB

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 }, &current.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