OXYGENHUB

Grants let certified keys act for an author (keyless step 5a) a2387d547373

An author signs a grant (spec §14.7: grantor, subject, issuer, scope,
window; domain and type `grant`) letting the keys an issuer certifies
for a subject such as agent:claude@andymccall act for them, as a
delegate does but with no key of their own. The issuer puts the signed
grant in each certificate it issues under it (certificate field 8, only
when present), so it travels with the certificate. The author-or-delegate
rule now accepts a signer whose certificate verifies with its issuer and
carries a grant that verifies with the change's author, matches the
certificate's subject and issuer, gives the scope and covers the
change's time. The policy still checks the certificate at a time the
signer did not choose.

A push now seals the configured author's unsigned changes when a
separate signer (author.signer: a delegate, or a key under a grant)
signs for them, so a ten-minute key need not sign at commit; the
receiver still checks it may. fsck decodes grants.

Refs oxygenhub#241
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-17 18:43 UTC
Signature
✔ signed by Andy McCall
Hash
a2387d547373968f42202e7e5f2897e504b01b03777c470089a4c74cdca13f2d
Depends on
02e691881a4e 05e71ed6cdf1 098227d48b1a 1983686fb7a7 387335ff7f4d 5b9c6ad45f6e 7ba155f20aa0 83e0677b07fe 982864068a59 b0e64e81e4fb c63fed2e0465 cb8b8082fa73 f0a517d28237 f53cbe7a204e fc1a466a1e66 ff0af1ee299d
Depended on by
6c3dbe2018e0 45a6f5ec85cf 1ec6fdbc6bcb 028545dfa7a4 e5f8eb2a2006 68dcf21fe055 c895bc52e4e0
In bookmarks
carried-not-removed doctor-trust identical-sides keyless-tags main merge-remote-bookmark paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state
In tags
v0.10.0 v0.11.0 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
38 atoms, 0 tree operations, 10 files

Browse the tree at this change

modified crates/oxy-core/src/cert.rs +189 −2
…
33 unchanged lines
34 34
    /// Valid from and until, in wall milliseconds.
35 35
    pub not_before: u64,
36 36
    pub expires: u64,
37 +
    /// The grant that lets the subject act for an author, when an agent
38 +
    /// asked with one (field 8, only when present).
39 +
    pub grant: Option<SignedGrant>,
37 40
}
38 41
39 42
impl Certificate {
40 43
    pub fn to_value(&self) -> Value {
41 44
        let mut claims = self.claims.clone();
42 45
        claims.sort();
43 -
        Value::map([
46 +
        let mut v = Value::map([
44 47
            (0, Value::Uint(1)),
45 48
            (1, Value::Bytes(self.key.encode())),
46 49
            (2, Value::Bytes(self.issuer.0.to_vec())),
…
19 unchanged lines
66 69
            ),
67 70
            (6, Value::Uint(self.not_before)),
68 71
            (7, Value::Uint(self.expires)),
69 -
        ])
72 +
        ]);
73 +
        if let (Some(g), Value::Map(m)) = (&self.grant, &mut v) {
74 +
            m.insert(8, Value::Bytes(g.encode()));
75 +
        }
76 +
        v
70 77
    }
71 78
72 79
    pub fn encode(&self) -> Vec<u8> {
…
41 unchanged lines
114 121
            scope,
115 122
            not_before: uint(field(m, 6, "not_before")?, "not_before")?,
116 123
            expires: uint(field(m, 7, "expires")?, "expires")?,
124 +
            grant: match m.get(&8) {
125 +
                Some(g) => Some(
126 +
                    SignedGrant::decode(bytes(g, "grant")?)
127 +
                        .map_err(|e| ObjectError::Schema(format!("grant: {e}")))?,
128 +
                ),
129 +
                None => None,
130 +
            },
117 131
        })
118 132
    }
119 133
…
13 unchanged lines
133 147
            .find(|(k, _)| k == name)
134 148
            .map(|(_, v)| v.as_str())
135 149
    }
150 +
}
151 +
152 +
/// An author's grant (spec §14.7): it lets the keys an issuer certifies
153 +
/// for `subject` act for the author, as a delegate does, without a key of
154 +
/// their own. An agent's fresh keys then sign and seal changes in the
155 +
/// author's name while the grant lasts; the grant travels inside their
156 +
/// certificates, so a verifier needs nothing else. Signed by the author.
157 +
#[derive(Clone, Debug, PartialEq, Eq)]
158 +
pub struct Grant {
159 +
    /// The author's KeyId, which signs the grant.
160 +
    pub grantor: KeyId,
161 +
    /// The subject whose certified keys act for it: `agent:NAME@ACCOUNT`.
162 +
    pub subject: String,
163 +
    /// The issuer whose certificates count.
164 +
    pub issuer: KeyId,
165 +
    /// What they may do for the author: changes, seal.
166 +
    pub scope: Scope,
167 +
    /// Valid from and until, in wall milliseconds.
168 +
    pub not_before: u64,
169 +
    pub expires: u64,
136 170
}
171 +
172 +
impl Grant {
173 +
    pub fn to_value(&self) -> Value {
174 +
        Value::map([
175 +
            (0, Value::Uint(1)),
176 +
            (1, Value::Bytes(self.grantor.0.to_vec())),
177 +
            (2, Value::Text(self.subject.clone())),
178 +
            (3, Value::Bytes(self.issuer.0.to_vec())),
179 +
            (
180 +
                4,
181 +
                Value::Array(
182 +
                    self.scope
183 +
                        .names()
184 +
                        .into_iter()
185 +
                        .map(|n| Value::Text(n.to_string()))
186 +
                        .collect(),
187 +
                ),
188 +
            ),
189 +
            (5, Value::Uint(self.not_before)),
190 +
            (6, Value::Uint(self.expires)),
191 +
        ])
192 +
    }
193 +
194 +
    pub fn encode(&self) -> Vec<u8> {
195 +
        cbor::encode(&self.to_value())
196 +
    }
197 +
198 +
    pub fn hash(&self) -> ObjectRef {
199 +
        hash_object(ObjectType::Grant, &self.encode())
200 +
    }
137 201
202 +
    pub fn decode(b: &[u8]) -> Result<Grant, ObjectError> {
203 +
        let v = cbor::decode(b)?;
204 +
        let m = map(&v, "grant")?;
205 +
        check_version(m)?;
206 +
        let mut scope = Scope::NONE;
207 +
        for n in array(field(m, 4, "scope")?, "scope")? {
208 +
            let n = text(n, "scope")?;
209 +
            scope = scope
210 +
                | Scope::named(n)
211 +
                    .ok_or_else(|| ObjectError::Schema(format!("grant: unknown scope {n:?}")))?;
212 +
        }
213 +
        Ok(Grant {
214 +
            grantor: oref(field(m, 1, "grantor")?, "grantor")?,
215 +
            subject: text(field(m, 2, "subject")?, "subject")?.to_owned(),
216 +
            issuer: oref(field(m, 3, "issuer")?, "issuer")?,
217 +
            scope,
218 +
            not_before: uint(field(m, 5, "not_before")?, "not_before")?,
219 +
            expires: uint(field(m, 6, "expires")?, "expires")?,
220 +
        })
221 +
    }
222 +
223 +
    /// Whether it lets its subject do `scope` for the author at `at_ms`
224 +
    /// (the change's own time, as for a delegation).
225 +
    pub fn covers(&self, scope: Scope, at_ms: u64) -> bool {
226 +
        !scope.is_empty()
227 +
            && self.scope.has(scope)
228 +
            && self.not_before <= at_ms
229 +
            && at_ms <= self.expires
230 +
    }
231 +
}
232 +
233 +
/// A grant with its author's signature over its ObjectRef.
234 +
#[derive(Clone, Debug, PartialEq, Eq)]
235 +
pub struct SignedGrant {
236 +
    pub grant: Grant,
237 +
    pub sig: Signature,
238 +
}
239 +
240 +
impl SignedGrant {
241 +
    /// Grant `grant`, signed by the author `grantor` holds the secret of.
242 +
    pub fn sign(grant: Grant, grantor: &Identity, sk: &SecretKey) -> SignedGrant {
243 +
        let sig = sk.sign(Domain::Grant, &grantor.hash(), &grant.hash());
244 +
        SignedGrant { grant, sig }
245 +
    }
246 +
247 +
    /// Signed by `grantor`, which it names, and well formed.
248 +
    pub fn verify(&self, grantor: &Identity) -> Result<(), CertError> {
249 +
        let g = &self.grant;
250 +
        if grantor.hash() != g.grantor || self.sig.key != g.grantor {
251 +
            return Err(CertError::WrongIssuer(g.grantor.short(12)));
252 +
        }
253 +
        verify(grantor, &self.sig, Domain::Grant, &g.hash())
254 +
            .map_err(|e| CertError::BadSignature(e.to_string()))?;
255 +
        if g.expires <= g.not_before {
256 +
            return Err(CertError::BadLifetime);
257 +
        }
258 +
        if g.scope.is_empty() {
259 +
            return Err(CertError::NoScope);
260 +
        }
261 +
        Ok(())
262 +
    }
263 +
264 +
    /// `[grant bytes, signature]`.
265 +
    pub fn encode(&self) -> Vec<u8> {
266 +
        cbor::encode(&Value::Array(vec![
267 +
            Value::Bytes(self.grant.encode()),
268 +
            self.sig.to_value(),
269 +
        ]))
270 +
    }
271 +
272 +
    pub fn decode(b: &[u8]) -> Result<SignedGrant, CertError> {
273 +
        let v = cbor::decode(b).map_err(ObjectError::from)?;
274 +
        let a = array(&v, "signed grant")?;
275 +
        if a.len() != 2 {
276 +
            return Err(CertError::Schema(
277 +
                "signed grant: expected [grant, signature]".into(),
278 +
            ));
279 +
        }
280 +
        Ok(SignedGrant {
281 +
            grant: Grant::decode(bytes(&a[0], "grant")?)?,
282 +
            sig: Signature::from_value(&a[1])?,
283 +
        })
284 +
    }
285 +
}
286 +
138 287
/// What a fresh key signs (a challenge, §14.3) to show its holder asks for
139 288
/// the certificate: bound to the credential it is asked with, so the
140 289
/// proof cannot be replayed with another.
…
101 unchanged lines
242 391
            scope: Scope::CHANGES | Scope::REFS,
243 392
            not_before: 1_000,
244 393
            expires: 1_000 + minutes * 60_000,
394 +
            grant: None,
245 395
        };
246 396
        (job_sk, SignedCertificate::sign(cert, issuer, issuer_sk))
247 397
    }
…
28 unchanged lines
276 426
            stolen.verify(&hub),
277 427
            Err(CertError::BadSignature(_))
278 428
        ));
429 +
    }
430 +
431 +
    #[test]
432 +
    fn a_grant_rides_in_its_certificate_and_verifies_with_its_author_only() {
433 +
        let hub_sk = SecretKey::generate();
434 +
        let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1);
435 +
        let ada_sk = SecretKey::generate();
436 +
        let ada = ada_sk.identity("Ada", "[email protected]", 1);
437 +
        let g = SignedGrant::sign(
438 +
            Grant {
439 +
                grantor: ada.hash(),
440 +
                subject: "agent:claude@ada".into(),
441 +
                issuer: hub.hash(),
442 +
                scope: Scope::CHANGES | Scope::SEAL,
443 +
                not_before: 1_000,
444 +
                expires: 2_000,
445 +
            },
446 +
            &ada,
447 +
            &ada_sk,
448 +
        );
449 +
        assert!(g.verify(&ada).is_ok());
450 +
        assert!(g.verify(&hub).is_err(), "only its author's");
451 +
        assert!(g.grant.covers(Scope::SEAL, 1_500));
452 +
        assert!(!g.grant.covers(Scope::REFS, 1_500));
453 +
        assert!(!g.grant.covers(Scope::SEAL, 2_001));
454 +
        let (_, plain) = issue(&hub_sk, &hub, 10);
455 +
        assert!(
456 +
            !matches!(cbor::decode(&plain.cert.encode()).unwrap(), Value::Map(m) if m.contains_key(&8)),
457 +
            "no grant, no field 8"
458 +
        );
459 +
        let mut with = plain.cert.clone();
460 +
        with.grant = Some(g.clone());
461 +
        let sc = SignedCertificate::sign(with, &hub, &hub_sk);
462 +
        let back = SignedCertificate::decode(&sc.encode()).unwrap();
463 +
        assert_eq!(back.cert.grant, Some(g));
464 +
        assert!(back.verify(&hub).is_ok());
465 +
        assert_ne!(back.cert.hash(), plain.cert.hash());
279 466
    }
280 467
281 468
    #[test]
…
45 unchanged lines
modified crates/oxy-core/src/hash.rs +5 −1
…
65 unchanged lines
66 66
    Receipt,
67 67
    /// A short-lived key's certificate (oxygenhub#241).
68 68
    Certificate,
69 +
    /// An author's grant to a certified subject (§14.7).
70 +
    Grant,
69 71
}
70 72
71 73
impl ObjectType {
72 -
    pub const ALL: [ObjectType; 11] = [
74 +
    pub const ALL: [ObjectType; 12] = [
73 75
        ObjectType::Chunk,
74 76
        ObjectType::Blob,
75 77
        ObjectType::Change,
…
5 unchanged lines
81 83
        ObjectType::State,
82 84
        ObjectType::Receipt,
83 85
        ObjectType::Certificate,
86 +
        ObjectType::Grant,
84 87
    ];
85 88
86 89
    pub fn name(self) -> &'static str {
…
9 unchanged lines
96 99
            ObjectType::State => "state",
97 100
            ObjectType::Receipt => "receipt",
98 101
            ObjectType::Certificate => "certificate",
102 +
            ObjectType::Grant => "grant",
99 103
        }
100 104
    }
101 105
…
59 unchanged lines
modified crates/oxy-core/src/identity.rs +3 −0
…
320 unchanged lines
321 321
    Challenge,
322 322
    /// A certificate an issuer gives a short-lived key (§14.7).
323 323
    Certificate,
324 +
    /// An author's grant to a certified subject (§14.7).
325 +
    Grant,
324 326
}
325 327
326 328
impl Domain {
…
4 unchanged lines
331 333
            Domain::Policy => "policy",
332 334
            Domain::Challenge => "challenge",
333 335
            Domain::Certificate => "certificate",
336 +
            Domain::Grant => "grant",
334 337
        }
335 338
    }
336 339
}
…
433 unchanged lines
modified crates/oxy-core/src/protocol.rs +1 −0
…
1224 unchanged lines
1225 1225
                scope: crate::identity::Scope::CHANGES,
1226 1226
                not_before: 1,
1227 1227
                expires: 2,
1228 +
                grant: None,
1228 1229
            },
1229 1230
            &issuer,
1230 1231
            &issuer_sk,
…
139 unchanged lines
modified crates/oxy-core/src/receipt.rs +1 −0
…
369 unchanged lines
370 370
                    scope,
371 371
                    not_before: from,
372 372
                    expires: to,
373 +
                    grant: None,
373 374
                },
374 375
                &hub,
375 376
                &hub_sk,
…
56 unchanged lines
modified crates/oxy-core/src/repo/fsck.rs +1 −0
…
101 unchanged lines
102 102
        ObjectType::Policy => crate::policy::Policy::decode(body).map(|_| ()),
103 103
        ObjectType::Receipt => crate::receipt::Receipt::decode(body).map(|_| ()),
104 104
        ObjectType::Certificate => crate::cert::Certificate::decode(body).map(|_| ()),
105 +
        ObjectType::Grant => crate::cert::Grant::decode(body).map(|_| ()),
105 106
        ObjectType::State => crate::objects::StateObject::decode(body).map(|_| ()),
106 107
    };
107 108
    r.map_err(|e| e.to_string())
…
406 unchanged lines
modified crates/oxy-core/src/repo/identity.rs +82 −6
…
459 unchanged lines
460 460
        Ok(SignatureStatus::Invalid(last_err))
461 461
    }
462 462
463 +
    /// Whether `signer` is a short-lived key acting for `author` under a
464 +
    /// grant (spec §14.7): its certificate verifies with its issuer and
465 +
    /// carries a grant `author` signed, for the certificate's subject and
466 +
    /// issuer, giving `needed` at `at_ms` (the change's time, as for a
467 +
    /// delegation). Whether the certificate covered the time it signed is
468 +
    /// the policy's check, at a time the signer did not choose. `None`
469 +
    /// when it does; otherwise why not.
470 +
    fn granted(
471 +
        &self,
472 +
        author: &KeyId,
473 +
        signer: &KeyId,
474 +
        needed: Scope,
475 +
        at_ms: u64,
476 +
    ) -> std::result::Result<Option<String>, String> {
477 +
        let neither = || {
478 +
            Some(format!(
479 +
                "key {} is neither the author nor a delegate",
480 +
                signer.short(12)
481 +
            ))
482 +
        };
483 +
        let Ok(Some(c)) = self.store.certificate_of(signer) else {
484 +
            return Ok(neither());
485 +
        };
486 +
        let Ok(sc) = self.store.get_certificate(&c) else {
487 +
            return Ok(neither());
488 +
        };
489 +
        let Some(g) = &sc.cert.grant else {
490 +
            return Ok(neither());
491 +
        };
492 +
        let who = signer.short(12);
493 +
        let issuer = self
494 +
            .store
495 +
            .get_identity(&sc.cert.issuer)
496 +
            .map_err(|_| format!("key {who}'s certificate names an unknown issuer"))?;
497 +
        sc.verify(&issuer)
498 +
            .map_err(|e| format!("key {who}'s certificate: {e}"))?;
499 +
        if g.grant.grantor != *author {
500 +
            return Ok(neither());
501 +
        }
502 +
        let grantor = self
503 +
            .store
504 +
            .get_identity(author)
505 +
            .map_err(|_| format!("key {who}'s grant is by an unknown author"))?;
506 +
        g.verify(&grantor)
507 +
            .map_err(|e| format!("key {who}'s grant: {e}"))?;
508 +
        if g.grant.subject != sc.cert.subject || g.grant.issuer != sc.cert.issuer {
509 +
            return Err(format!(
510 +
                "key {who}'s grant is for {} from issuer {}, not its certificate's",
511 +
                g.grant.subject,
512 +
                g.grant.issuer.short(12)
513 +
            ));
514 +
        }
515 +
        if !sc.cert.scope.has(needed) || !g.grant.scope.has(needed) {
516 +
            return Err(format!("key {who} is not granted {needed}"));
517 +
        }
518 +
        if !g.grant.covers(needed, at_ms) {
519 +
            return Err(format!(
520 +
                "key {who}'s grant did not cover the time the change was made"
521 +
            ));
522 +
        }
523 +
        Ok(None)
524 +
    }
525 +
463 526
    /// Verify `sig` over `target` and check the author-or-delegate rule for `change`.
464 527
    fn check_signer(
465 528
        &self,
…
23 unchanged lines
489 552
                .ok()
490 553
                .and_then(|a| a.delegate(&sig.key).copied());
491 554
            match delegation {
555 +
                // A certified key acting under the author's grant (§14.7).
492 556
                None => {
493 -
                    return Err(format!(
494 -
                        "key {} is neither the author nor a delegate",
495 -
                        sig.key.short(12)
496 -
                    ))
557 +
                    if let Some(why) =
558 +
                        self.granted(&change.author, &sig.key, needed, change.hlc.wall_ms)?
559 +
                    {
560 +
                        return Err(why);
561 +
                    }
497 562
                }
498 563
                Some(d) if !d.scope.has(needed) => {
499 564
                    return Err(format!(
…
123 unchanged lines
623 688
            self.store.index_seal(h, &r)?;
624 689
        }
625 690
        Ok(r)
691 +
    }
692 +
693 +
    /// Whether `signer` seals changes by `author`: its own, anonymous ones
694 +
    /// (§14.4), and, when it signs for the configured author (a delegate,
695 +
    /// or a short-lived key under the author's grant, §14.7), the
696 +
    /// author's. A receiver still checks it may.
697 +
    pub(crate) fn seals_for_author(&self, author: &KeyId, signer: &KeyId) -> bool {
698 +
        author == signer
699 +
            || author.is_zero()
700 +
            || (self.config.signer_key.is_some() && self.config.author_key == Some(*author))
626 701
    }
627 702
628 703
    /// The changes of the current bookmark this identity can authenticate
629 -
    /// but has not: its own unsigned ones and anonymous ones.
704 +
    /// but has not: its own unsigned ones, anonymous ones, and the
705 +
    /// author's when it signs for them.
630 706
    pub fn sealable(&self) -> Result<Vec<ChangeHash>> {
631 707
        let Some((key, _)) = self.secret_key()? else {
632 708
            return fail("no identity: run oxy identity create");
…
2 unchanged lines
635 711
        let state = self.load_state_history(&heads)?;
636 712
        let mut out = Vec::new();
637 713
        for (h, meta) in state.changes() {
638 -
            if (meta.author == key || meta.author.is_zero())
714 +
            if self.seals_for_author(&meta.author, &key)
639 715
                && !matches!(self.authenticate_change(h)?, SignatureStatus::Valid { .. })
640 716
            {
641 717
                out.push(*h);
…
24 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +1 −1
…
1999 unchanged lines
2000 2000
            let mut unsigned = Vec::new();
2001 2001
            for h in &to_send {
2002 2002
                let c = self.store.get_change_meta(h)?;
2003 -
                if (c.author == key || c.author.is_zero())
2003 +
                if self.seals_for_author(&c.author, &key)
2004 2004
                    && !matches!(self.authenticate_change(h)?, SignatureStatus::Valid { .. })
2005 2005
                {
2006 2006
                    unsigned.push(*h);
…
327 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +126 −0
…
1041 unchanged lines
1042 1042
                scope: Scope::CHANGES | Scope::REFS,
1043 1043
                not_before: from,
1044 1044
                expires: to,
1045 +
                grant: None,
1045 1046
            },
1046 1047
            &hub,
1047 1048
            &hub_sk,
…
78 unchanged lines
1126 1127
    );
1127 1128
}
1128 1129
1130 +
/// Keyless step 5 (oxygenhub#241): an agent's fresh key, certified for a
1131 +
/// subject the author granted, seals changes in the author's name; with
1132 +
/// no grant, a stranger's, or one that had ended, it is neither the
1133 +
/// author nor a delegate.
1129 1134
#[test]
1135 +
fn a_certified_key_acts_for_its_author_under_a_grant() {
1136 +
    use crate::cert::{Certificate, Grant, SignedCertificate, SignedGrant};
1137 +
    use crate::identity::{Identity, Scope};
1138 +
    hermetic();
1139 +
    let dir = tempfile::tempdir().unwrap();
1140 +
    let root = dir.path();
1141 +
    let _origin = Repo::init(&root.join("origin")).unwrap();
1142 +
    let url = root.join("origin").to_string_lossy().to_string();
1143 +
    let mut a = Repo::clone(&url, &root.join("a"))
1144 +
        .unwrap()
1145 +
        .with_home(root.join("home-a"));
1146 +
    let (ada, _) = a
1147 +
        .create_identity("Ada", "[email protected]", IdentityScope::User)
1148 +
        .unwrap();
1149 +
    write(&root.join("a"), "a.txt", b"a\n");
1150 +
    a.commit("a", &[]).unwrap();
1151 +
    a.policy_init().unwrap();
1152 +
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
1153 +
    let hub_sk = SecretKey::generate();
1154 +
    let hub = hub_sk.identity("hub", "[email protected]", 1);
1155 +
    a.policy_grant_subject(
1156 +
        "agent:claude@ada",
1157 +
        hub.clone(),
1158 +
        Scope::CHANGES | Scope::REFS,
1159 +
        None,
1160 +
    )
1161 +
    .unwrap();
1162 +
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
1163 +
    let (_, ada_sk) = a.secret_key().unwrap().unwrap();
1164 +
    let ada_id = a.identity(&ada).unwrap();
1165 +
1166 +
    let now = Hlc::now().wall_ms;
1167 +
    let grant = |by: &Identity, sk: &SecretKey, from: u64, to: u64| {
1168 +
        SignedGrant::sign(
1169 +
            Grant {
1170 +
                grantor: by.hash(),
1171 +
                subject: "agent:claude@ada".into(),
1172 +
                issuer: hub.hash(),
1173 +
                scope: Scope::CHANGES | Scope::SEAL,
1174 +
                not_before: from,
1175 +
                expires: to,
1176 +
            },
1177 +
            by,
1178 +
            sk,
1179 +
        )
1180 +
    };
1181 +
    // Ada's work as an agent runs it: Ada the author, a fresh key the hub
1182 +
    // certified (with `g` inside) the signer.
1183 +
    let agent = |name: &str, g: Option<SignedGrant>| {
1184 +
        let mut r = Repo::clone(&url, &root.join(name))
1185 +
            .unwrap()
1186 +
            .with_home(root.join(format!("home-{name}")));
1187 +
        let (key, id) = r
1188 +
            .create_identity(name, "[email protected]", IdentityScope::User)
1189 +
            .unwrap();
1190 +
        r.set_user_config("author.key", &ada.to_hex()).unwrap();
1191 +
        r.set_user_config("author.signer", &key.to_hex()).unwrap();
1192 +
        let cert = SignedCertificate::sign(
1193 +
            Certificate {
1194 +
                key: id,
1195 +
                issuer: hub.hash(),
1196 +
                subject: "agent:claude@ada".into(),
1197 +
                claims: vec![],
1198 +
                scope: Scope::CHANGES | Scope::REFS | Scope::SEAL,
1199 +
                not_before: now - 60_000,
1200 +
                expires: now + 600_000,
1201 +
                grant: g,
1202 +
            },
1203 +
            &hub,
1204 +
            &hub_sk,
1205 +
        );
1206 +
        r.store().put_identity(&hub).unwrap();
1207 +
        r.store().put_certificate(&cert).unwrap();
1208 +
        write(&root.join(name), &format!("{name}.txt"), b"agent work\n");
1209 +
        let h = r.commit("work", &[]).unwrap();
1210 +
        assert_eq!(r.store().get_change_meta(&h).unwrap().author, ada);
1211 +
        (r, key, h)
1212 +
    };
1213 +
1214 +
    let (claude, key, h) = agent(
1215 +
        "claude",
1216 +
        Some(grant(&ada_id, &ada_sk, now - 60_000, now + 86_400_000)),
1217 +
    );
1218 +
    let r = claude.push("origin", false).unwrap();
1219 +
    assert!(r.iter().all(|x| x.ok), "{r:?}");
1220 +
    let host = Repo::open(&root.join("origin")).unwrap();
1221 +
    assert!(matches!(
1222 +
        host.authenticate_change(&h).unwrap(),
1223 +
        SignatureStatus::Valid { key: k, sealed: true, .. } if k == key
1224 +
    ));
1225 +
    a.pull("origin").unwrap();
1226 +
    assert!(matches!(
1227 +
        a.authenticate_change(&h).unwrap(),
1228 +
        SignatureStatus::Valid { key: k, .. } if k == key
1229 +
    ));
1230 +
1231 +
    let (none, _, _) = agent("none", None);
1232 +
    let r = none.push("origin", false).unwrap();
1233 +
    assert!(r.iter().all(|x| !x.ok), "no grant: {r:?}");
1234 +
    let mallory_sk = SecretKey::generate();
1235 +
    let mallory = mallory_sk.identity("Mallory", "[email protected]", 1);
1236 +
    let (stranger, _, _) = agent(
1237 +
        "stranger",
1238 +
        Some(grant(&mallory, &mallory_sk, now - 60_000, now + 600_000)),
1239 +
    );
1240 +
    let r = stranger.push("origin", false).unwrap();
1241 +
    assert!(r.iter().all(|x| !x.ok), "a stranger's grant: {r:?}");
1242 +
    let (ended, _, eh) = agent(
1243 +
        "ended",
1244 +
        Some(grant(&ada_id, &ada_sk, now - 7_200_000, now - 3_600_000)),
1245 +
    );
1246 +
    let r = ended.push("origin", false).unwrap();
1247 +
    assert!(r.iter().all(|x| !x.ok), "an ended grant: {r:?}");
1248 +
    assert!(matches!(
1249 +
        ended.authenticate_change(&eh).unwrap(),
1250 +
        SignatureStatus::Invalid(e) if e.contains("grant did not cover")
1251 +
    ));
1252 +
}
1253 +
1254 +
#[test]
1130 1255
fn trust_policy_governs_pushes_and_fetches() {
1131 1256
    hermetic();
1132 1257
    let dir = tempfile::tempdir().unwrap();
…
4613 unchanged lines
5746 5871
            scope: crate::identity::Scope::SEAL,
5747 5872
            not_before: now - 1_000,
5748 5873
            expires: now + 600_000,
5874 +
            grant: None,
5749 5875
        },
5750 5876
        &hub,
5751 5877
        &hub_sk,
…
2631 unchanged lines
modified docs/spec.md +19 −2
…
617 unchanged lines
618 618
  ml   [3]: bytes(3309)
619 619
}
620 620
message = "oxy1 sig " || domain || 0x00 || KeyId || ObjectRef-being-signed
621 -
domain  = "change" | "attest" | "policy"
621 +
domain  = "change" | "attest" | "policy" | "certificate" | "grant"
622 622
```
623 623
624 624
Both signatures are computed over the same `message`, which includes the `KeyId` of the composite pair. Verification MUST require both signatures to verify with the keys of the identity named by `key`. A signature with either half missing, empty, or failing is invalid. This is the same construction as the IETF LAMPS composite signature drafts and prevents stripping the post-quantum half.
…
30 unchanged lines
655 655
656 656
**Cadence.** With the default `core.sign = "seal"`, changes are left unsigned locally and `oxy push` creates one seal covering every change in the push that carries no individual signature, immediately before the reference attestation. Seals are created only at push: a seal made earlier would be invalidated by any amend, since rewriting produces a new hash. With `core.sign = "change"`, every change is signed at commit and no seal is needed. Policy (§14.5) decides which the receiving repository accepts.
657 657
658 -
**Verification.** A sealed change `C` is authenticated iff a seal `S` exists such that `hash(C) ∈ S.body.hashes`, `S` carries a valid signature from key `K`, and `K` equals `C.author` or is listed in `C.author`'s identity as a delegate with the `seal` scope, not expired at `C.hlc.wall_ms` (§14.2). The same author-or-delegate rule applies to individual change signatures, with the `changes` scope. This rule is what stops a collaborator from sealing a change whose `author` field names someone else (§3.1). A change whose `author` is the zero KeyId names nobody (history imported from Git, §21, or made before an identity existed), so a seal from any key authenticates it: the sealer **adopts** it, and policy authorizes the sealer for the reference as usual. A push seals the pusher's own unauthenticated changes and adopts anonymous ones in the same seal. A seal's signer must additionally be authorized by policy for the reference being pushed.
658 +
**Verification.** A sealed change `C` is authenticated iff a seal `S` exists such that `hash(C) ∈ S.body.hashes`, `S` carries a valid signature from key `K`, and `K` equals `C.author` or is listed in `C.author`'s identity as a delegate with the `seal` scope, not expired at `C.hlc.wall_ms` (§14.2). The same author-or-delegate rule applies to individual change signatures, with the `changes` scope. This rule is what stops a collaborator from sealing a change whose `author` field names someone else (§3.1). A change whose `author` is the zero KeyId names nobody (history imported from Git, §21, or made before an identity existed), so a seal from any key authenticates it: the sealer **adopts** it, and policy authorizes the sealer for the reference as usual. A push seals the pusher's own unauthenticated changes and adopts anonymous ones in the same seal; a pusher that signs for its configured author (`author.signer`: a delegate, or a short-lived key under the author's grant, §14.7) seals the author's as well, and the receiver checks it may. A seal's signer must additionally be authorized by policy for the reference being pushed.
659 659
660 660
**Handling.** A seal is stored and transported with the changes it covers: `fetch` (§16.1) streams the seals covering every change it returns, `push` (§16.4) MUST include a seal or signature for every change, and a seal covering any reachable change is itself reachable (§21). Seals from different keys MAY overlap; a change needs at least one valid authenticator, and policy thresholds count distinct authenticating keys.
661 661
…
67 unchanged lines
729 729
  scope       [5]: [text]         // changes, refs, seal
730 730
  not_before  [6]: u64            // wall milliseconds
731 731
  expires     [7]: u64
732 +
  grant       [8]: bytes          // optional: a signed grant (below), only when present
732 733
}
733 734
```
734 735
735 736
A signed certificate is `[certificate bytes, Signature]`: the issuer's composite signature, in domain `certificate` (§14.3), over the certificate's ObjectRef (type `certificate`). It is valid when the signature verifies with the identity its `issuer` names, `expires` is after `not_before` and at most an hour later, and its scope is not empty. A signature by the short-lived key names the key's KeyId as any signature does; it counts only within the certificate's scope and window, and the time it is checked at must be one the signer did not choose (a host's record of when it accepted the signature, or the transparency log's), never the signed object's own clock. Which issuers a repository trusts, and for which subjects, its trust policy says (§14.5).
736 737
738 +
**Grants.** An author lets the keys an issuer certifies for a subject act for it, as a delegate does but without a key of their own (an agent's fresh keys, oxygenhub docs/keyless.md):
739 +
740 +
```
741 +
Grant {
742 +
  version     [0]: 1
743 +
  grantor     [1]: KeyId          // the author, whose key signs the grant
744 +
  subject     [2]: text           // "agent:NAME@ACCOUNT"
745 +
  issuer      [3]: KeyId          // whose certificates for the subject count
746 +
  scope       [4]: [text]         // changes, seal
747 +
  not_before  [5]: u64            // wall milliseconds
748 +
  expires     [6]: u64
749 +
}
750 +
```
751 +
752 +
A signed grant is `[grant bytes, Signature]`: the grantor's composite signature, in domain `grant`, over the grant's ObjectRef (type `grant`). It needs no maximum lifetime: it gives nothing until an issuer certifies a key under it, and the grantor revokes it by asking the issuer to stop. The issuer puts it in each certificate it issues under it (field 8), so it travels with the certificate and a verifier needs nothing else. A signature by a key that is neither a change's author nor its delegate (§14.4) still satisfies the author-or-delegate rule when the key's certificate verifies with its issuer and carries a grant that verifies with the change's author, names the certificate's subject and issuer, gives the scope needed (`changes` for a change signature, `seal` for a seal) in both the grant and the certificate, and covers the change's `hlc.wall_ms`, as a delegation does. Whether the certificate covered the time the key signed is the policy's check (§14.5), at a time the signer did not choose.
753 +
737 754
**Acceptance.** A host that issues certificates records when it accepted a reference attestation signed by a short-lived key: an `accepted` attestation (§14.4) naming that attestation's ObjectRef, its `hlc` the host's clock at the push, signed in domain `attest` by the issuer's key. The host keeps it indexed by the attestation it accepts (gc keeps it beside the bookmark's latest attestation) and serves it with the bookmark (§16, `refs` key 5). A fetch checks the certificate at the acceptance's time only when the acceptance names exactly the attestation being checked and verifies with the certificate's issuer; otherwise, including when there is none, at the attestation's own time. An acceptance by any other key changes nothing: the issuer is already trusted to vouch for the key, so it adds no trust it did not have.
738 755
739 756
## 15. Transport
…
345 unchanged lines