OXYGENHUB

Certificates for short-lived keys (keyless, step 1) 02e691881a4e

A new object, certificate: an issuer (a hub's key) binds a fresh key's
identity to a subject (an OIDC subject: a pipeline on a bookmark, an
agent for an account) with claims, a scope and a window of at most an
hour, signed with the issuer's composite key in a domain of its own, so
it is post-quantum like every other signature. covers() checks scope and
window against a time the caller supplies, which must not be the signer's
own. Spec 14.7. Tests: round trip, the issuer's signature only, a forged
subject or a stolen signature refused, scope and window, an over-long
lifetime refused, a signature by the short-lived key.

Refs andymccall/oxygenhub#241
Author
Andy McCall @andymccall <[email protected]>
via
agent Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
When
· 2026-10-17 14:02 UTC
Signature
✔ signed by Claude Code
Hash
02e691881a4e2663acf3af94905a759fc0bde2791d3264f6ee76bfe7dc30eec2
Depends on
1983686fb7a7 387335ff7f4d 7aa2dc9369e3 7ba155f20aa0 a39754d02c58 f9c98deef326 fc1a466a1e66 ff0af1ee299d
Depended on by
028545dfa7a4 a2387d547373 f53cbe7a204e 098227d48b1a
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
13 atoms, 1 tree operation, 6 files

Browse the tree at this change

added crates/oxy-core/src/cert.rs +300 −0
1 +
//! Certificates for short-lived keys (spec §14.7, oxygenhub#241,
2 +
//! oxygenhub docs/keyless.md). An issuer, a hub's key, binds a fresh key
3 +
//! to the identity that proved itself (an OIDC subject) for minutes: a Flow
4 +
//! job or an agent signs with that key and throws it away. The certificate
5 +
//! is signed with the issuer's composite key, so it is post-quantum like
6 +
//! every other signature in Oxygen.
7 +
//!
8 +
//! Whether to trust an issuer, and for which subjects, is the trust
9 +
//! policy's (§14.5); whether a signature was made inside the certificate's
10 +
//! window needs time the signer does not choose, which the caller passes
11 +
//! in (a host's acceptance now, the transparency log's later).
12 +
13 +
use crate::cbor::{self, Value};
14 +
use crate::hash::{hash_object, KeyId, ObjectRef, ObjectType};
15 +
use crate::identity::{verify, Domain, Identity, Scope, SecretKey, Signature};
16 +
use crate::objects::{array, bytes, check_version, field, map, oref, text, uint, ObjectError};
17 +
18 +
/// The longest a certificate may be valid. Jobs get ten minutes; an hour
19 +
/// leaves room for an issuer that rounds up, and no more.
20 +
pub const MAX_LIFETIME_MS: u64 = 60 * 60 * 1000;
21 +
22 +
#[derive(Clone, Debug, PartialEq, Eq)]
23 +
pub struct Certificate {
24 +
    /// The short-lived key's public identity.
25 +
    pub key: Identity,
26 +
    /// The issuer's KeyId (a hub's key).
27 +
    pub issuer: KeyId,
28 +
    /// Who proved it: `repo:OWNER/NAME:bookmark:B`, `agent:NAME@ACCOUNT`.
29 +
    pub subject: String,
30 +
    /// What else the proof said (environment, run, job), sorted by name.
31 +
    pub claims: Vec<(String, String)>,
32 +
    /// What the key may sign: changes, refs, seals.
33 +
    pub scope: Scope,
34 +
    /// Valid from and until, in wall milliseconds.
35 +
    pub not_before: u64,
36 +
    pub expires: u64,
37 +
}
38 +
39 +
impl Certificate {
40 +
    pub fn to_value(&self) -> Value {
41 +
        let mut claims = self.claims.clone();
42 +
        claims.sort();
43 +
        Value::map([
44 +
            (0, Value::Uint(1)),
45 +
            (1, Value::Bytes(self.key.encode())),
46 +
            (2, Value::Bytes(self.issuer.0.to_vec())),
47 +
            (3, Value::Text(self.subject.clone())),
48 +
            (
49 +
                4,
50 +
                Value::Array(
51 +
                    claims
52 +
                        .into_iter()
53 +
                        .map(|(k, v)| Value::Array(vec![Value::Text(k), Value::Text(v)]))
54 +
                        .collect(),
55 +
                ),
56 +
            ),
57 +
            (
58 +
                5,
59 +
                Value::Array(
60 +
                    self.scope
61 +
                        .names()
62 +
                        .into_iter()
63 +
                        .map(|n| Value::Text(n.to_string()))
64 +
                        .collect(),
65 +
                ),
66 +
            ),
67 +
            (6, Value::Uint(self.not_before)),
68 +
            (7, Value::Uint(self.expires)),
69 +
        ])
70 +
    }
71 +
72 +
    pub fn encode(&self) -> Vec<u8> {
73 +
        cbor::encode(&self.to_value())
74 +
    }
75 +
76 +
    pub fn hash(&self) -> ObjectRef {
77 +
        hash_object(ObjectType::Certificate, &self.encode())
78 +
    }
79 +
80 +
    /// The short-lived key's KeyId: what its signatures name.
81 +
    pub fn key_id(&self) -> KeyId {
82 +
        self.key.hash()
83 +
    }
84 +
85 +
    pub fn decode(b: &[u8]) -> Result<Certificate, ObjectError> {
86 +
        let v = cbor::decode(b)?;
87 +
        let m = map(&v, "certificate")?;
88 +
        check_version(m)?;
89 +
        let key = Identity::decode(bytes(field(m, 1, "key")?, "key")?)?;
90 +
        let mut claims = Vec::new();
91 +
        for c in array(field(m, 4, "claims")?, "claims")? {
92 +
            let pair = array(c, "claim")?;
93 +
            if pair.len() != 2 {
94 +
                return Err(ObjectError::Schema("claim: expected [name, value]".into()));
95 +
            }
96 +
            claims.push((
97 +
                text(&pair[0], "claim name")?.to_owned(),
98 +
                text(&pair[1], "claim value")?.to_owned(),
99 +
            ));
100 +
        }
101 +
        let mut scope = Scope::NONE;
102 +
        for n in array(field(m, 5, "scope")?, "scope")? {
103 +
            let n = text(n, "scope")?;
104 +
            scope = scope
105 +
                | Scope::named(n).ok_or_else(|| {
106 +
                    ObjectError::Schema(format!("certificate: unknown scope {n:?}"))
107 +
                })?;
108 +
        }
109 +
        Ok(Certificate {
110 +
            key,
111 +
            issuer: oref(field(m, 2, "issuer")?, "issuer")?,
112 +
            subject: text(field(m, 3, "subject")?, "subject")?.to_owned(),
113 +
            claims,
114 +
            scope,
115 +
            not_before: uint(field(m, 6, "not_before")?, "not_before")?,
116 +
            expires: uint(field(m, 7, "expires")?, "expires")?,
117 +
        })
118 +
    }
119 +
120 +
    /// Whether the key may sign within `scope` at `at_ms`, a time the
121 +
    /// signer did not choose.
122 +
    pub fn covers(&self, scope: Scope, at_ms: u64) -> bool {
123 +
        !scope.is_empty()
124 +
            && self.scope.has(scope)
125 +
            && self.not_before <= at_ms
126 +
            && at_ms <= self.expires
127 +
    }
128 +
129 +
    /// The claim called `name`, if the certificate has one.
130 +
    pub fn claim(&self, name: &str) -> Option<&str> {
131 +
        self.claims
132 +
            .iter()
133 +
            .find(|(k, _)| k == name)
134 +
            .map(|(_, v)| v.as_str())
135 +
    }
136 +
}
137 +
138 +
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
139 +
pub enum CertError {
140 +
    #[error("the certificate names issuer {0}, not this one")]
141 +
    WrongIssuer(String),
142 +
    #[error("the issuer's signature does not verify: {0}")]
143 +
    BadSignature(String),
144 +
    #[error("the certificate is valid for no time, or for longer than an hour")]
145 +
    BadLifetime,
146 +
    #[error("the certificate gives no scope")]
147 +
    NoScope,
148 +
    #[error("{0}")]
149 +
    Schema(String),
150 +
}
151 +
152 +
impl From<ObjectError> for CertError {
153 +
    fn from(e: ObjectError) -> Self {
154 +
        CertError::Schema(e.to_string())
155 +
    }
156 +
}
157 +
158 +
/// A certificate with its issuer's signature over its ObjectRef.
159 +
#[derive(Clone, Debug, PartialEq, Eq)]
160 +
pub struct SignedCertificate {
161 +
    pub cert: Certificate,
162 +
    pub sig: Signature,
163 +
}
164 +
165 +
impl SignedCertificate {
166 +
    /// Issue `cert`, signed by the issuer `issuer` holds the secret of.
167 +
    pub fn sign(mut cert: Certificate, issuer: &Identity, sk: &SecretKey) -> SignedCertificate {
168 +
        // Claims in their canonical order, as encoded.
169 +
        cert.claims.sort();
170 +
        let sig = sk.sign(Domain::Certificate, &issuer.hash(), &cert.hash());
171 +
        SignedCertificate { cert, sig }
172 +
    }
173 +
174 +
    /// Signed by `issuer`, which it names, and well formed. Whether to
175 +
    /// trust the issuer for its subject is the policy's question.
176 +
    pub fn verify(&self, issuer: &Identity) -> Result<(), CertError> {
177 +
        let c = &self.cert;
178 +
        if issuer.hash() != c.issuer || self.sig.key != c.issuer {
179 +
            return Err(CertError::WrongIssuer(c.issuer.short(12)));
180 +
        }
181 +
        verify(issuer, &self.sig, Domain::Certificate, &c.hash())
182 +
            .map_err(|e| CertError::BadSignature(e.to_string()))?;
183 +
        if c.expires <= c.not_before || c.expires - c.not_before > MAX_LIFETIME_MS {
184 +
            return Err(CertError::BadLifetime);
185 +
        }
186 +
        if c.scope.is_empty() {
187 +
            return Err(CertError::NoScope);
188 +
        }
189 +
        Ok(())
190 +
    }
191 +
192 +
    /// `[certificate bytes, signature]`.
193 +
    pub fn encode(&self) -> Vec<u8> {
194 +
        cbor::encode(&Value::Array(vec![
195 +
            Value::Bytes(self.cert.encode()),
196 +
            self.sig.to_value(),
197 +
        ]))
198 +
    }
199 +
200 +
    pub fn decode(b: &[u8]) -> Result<SignedCertificate, CertError> {
201 +
        let v = cbor::decode(b).map_err(ObjectError::from)?;
202 +
        let a = array(&v, "signed certificate")?;
203 +
        if a.len() != 2 {
204 +
            return Err(CertError::Schema(
205 +
                "signed certificate: expected [certificate, signature]".into(),
206 +
            ));
207 +
        }
208 +
        Ok(SignedCertificate {
209 +
            cert: Certificate::decode(bytes(&a[0], "certificate")?)?,
210 +
            sig: Signature::from_value(&a[1])?,
211 +
        })
212 +
    }
213 +
}
214 +
215 +
#[cfg(test)]
216 +
mod tests {
217 +
    use super::*;
218 +
219 +
    fn issue(
220 +
        issuer_sk: &SecretKey,
221 +
        issuer: &Identity,
222 +
        minutes: u64,
223 +
    ) -> (SecretKey, SignedCertificate) {
224 +
        let job_sk = SecretKey::generate();
225 +
        let cert = Certificate {
226 +
            key: job_sk.identity("job 12 build", "[email protected]", 1_000),
227 +
            issuer: issuer.hash(),
228 +
            subject: "repo:andy/demo:bookmark:main".into(),
229 +
            claims: vec![
230 +
                ("run".into(), "12".into()),
231 +
                ("environment".into(), "prod".into()),
232 +
            ],
233 +
            scope: Scope::CHANGES | Scope::REFS,
234 +
            not_before: 1_000,
235 +
            expires: 1_000 + minutes * 60_000,
236 +
        };
237 +
        (job_sk, SignedCertificate::sign(cert, issuer, issuer_sk))
238 +
    }
239 +
240 +
    #[test]
241 +
    fn a_certificate_round_trips_and_verifies_with_its_issuer_only() {
242 +
        let hub_sk = SecretKey::generate();
243 +
        let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1);
244 +
        let (_, sc) = issue(&hub_sk, &hub, 10);
245 +
        let back = SignedCertificate::decode(&sc.encode()).unwrap();
246 +
        assert_eq!(back, sc);
247 +
        assert_eq!(back.cert.claims[0].0, "environment", "claims kept sorted");
248 +
        assert_eq!(back.cert.claim("run"), Some("12"));
249 +
        assert!(back.verify(&hub).is_ok());
250 +
        let other_sk = SecretKey::generate();
251 +
        let other = other_sk.identity("elsewhere", "[email protected]", 1);
252 +
        assert!(matches!(
253 +
            back.verify(&other),
254 +
            Err(CertError::WrongIssuer(_))
255 +
        ));
256 +
        // Changed after signing: the signature no longer covers it.
257 +
        let mut forged = back.clone();
258 +
        forged.cert.subject = "repo:andy/demo:bookmark:release".into();
259 +
        assert!(matches!(
260 +
            forged.verify(&hub),
261 +
            Err(CertError::BadSignature(_))
262 +
        ));
263 +
        // Signed by another key in the issuer's name.
264 +
        let mut stolen = back.clone();
265 +
        stolen.sig = other_sk.sign(Domain::Certificate, &hub.hash(), &back.cert.hash());
266 +
        assert!(matches!(
267 +
            stolen.verify(&hub),
268 +
            Err(CertError::BadSignature(_))
269 +
        ));
270 +
    }
271 +
272 +
    #[test]
273 +
    fn it_covers_its_scope_and_window_only() {
274 +
        let hub_sk = SecretKey::generate();
275 +
        let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1);
276 +
        let (_, sc) = issue(&hub_sk, &hub, 10);
277 +
        let c = &sc.cert;
278 +
        assert!(c.covers(Scope::CHANGES, 1_000) && c.covers(Scope::REFS, 1_000 + 10 * 60_000));
279 +
        assert!(!c.covers(Scope::SEAL, 2_000), "not a scope it gives");
280 +
        assert!(!c.covers(Scope::CHANGES, 999), "before it");
281 +
        assert!(
282 +
            !c.covers(Scope::CHANGES, 1_000 + 10 * 60_000 + 1),
283 +
            "after it"
284 +
        );
285 +
        assert!(!c.covers(Scope::NONE, 2_000));
286 +
        let (_, long) = issue(&hub_sk, &hub, 61);
287 +
        assert_eq!(long.verify(&hub), Err(CertError::BadLifetime));
288 +
    }
289 +
290 +
    #[test]
291 +
    fn a_signature_by_the_short_lived_key_names_it() {
292 +
        let hub_sk = SecretKey::generate();
293 +
        let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1);
294 +
        let (job_sk, sc) = issue(&hub_sk, &hub, 10);
295 +
        let target = ObjectRef([5; 32]);
296 +
        let sig = job_sk.sign(Domain::Change, &sc.cert.key_id(), &target);
297 +
        assert_eq!(sig.key, sc.cert.key_id());
298 +
        assert!(verify(&sc.cert.key, &sig, Domain::Change, &target).is_ok());
299 +
    }
300 +
}
modified crates/oxy-core/src/hash.rs +5 −1
…
63 unchanged lines
64 64
    Policy,
65 65
    State,
66 66
    Receipt,
67 +
    /// A short-lived key's certificate (oxygenhub#241).
68 +
    Certificate,
67 69
}
68 70
69 71
impl ObjectType {
70 -
    pub const ALL: [ObjectType; 10] = [
72 +
    pub const ALL: [ObjectType; 11] = [
71 73
        ObjectType::Chunk,
72 74
        ObjectType::Blob,
73 75
        ObjectType::Change,
…
4 unchanged lines
78 80
        ObjectType::Policy,
79 81
        ObjectType::State,
80 82
        ObjectType::Receipt,
83 +
        ObjectType::Certificate,
81 84
    ];
82 85
83 86
    pub fn name(self) -> &'static str {
…
8 unchanged lines
92 95
            ObjectType::Policy => "policy",
93 96
            ObjectType::State => "state",
94 97
            ObjectType::Receipt => "receipt",
98 +
            ObjectType::Certificate => "certificate",
95 99
        }
96 100
    }
97 101
…
59 unchanged lines
modified crates/oxy-core/src/identity.rs +4 −1
…
59 unchanged lines
60 60
            .map(|(_, n)| *n)
61 61
            .collect()
62 62
    }
63 -
    fn named(name: &str) -> Option<Scope> {
63 +
    pub(crate) fn named(name: &str) -> Option<Scope> {
64 64
        Self::NAMES
65 65
            .iter()
66 66
            .find(|(_, n)| *n == name)
…
252 unchanged lines
319 319
    /// A server-issued challenge, proving possession of a key to a host
320 320
    /// (hub spec §5.3, §5.4). Never a stored object.
321 321
    Challenge,
322 +
    /// A certificate an issuer gives a short-lived key (§14.7).
323 +
    Certificate,
322 324
}
323 325
324 326
impl Domain {
…
3 unchanged lines
328 330
            Domain::Attest => "attest",
329 331
            Domain::Policy => "policy",
330 332
            Domain::Challenge => "challenge",
333 +
            Domain::Certificate => "certificate",
331 334
        }
332 335
    }
333 336
}
…
433 unchanged lines
modified crates/oxy-core/src/lib.rs +1 −0
…
4 unchanged lines
5 5
pub mod attest;
6 6
pub mod attributes;
7 7
pub mod cbor;
8 +
pub mod cert;
8 9
pub mod chunk;
9 10
pub mod diff;
10 11
pub mod dsse;
…
20 unchanged lines
modified crates/oxy-core/src/repo/fsck.rs +1 −0
…
97 unchanged lines
98 98
        ObjectType::Identity => Identity::decode(body).map(|_| ()),
99 99
        ObjectType::Policy => crate::policy::Policy::decode(body).map(|_| ()),
100 100
        ObjectType::Receipt => crate::receipt::Receipt::decode(body).map(|_| ()),
101 +
        ObjectType::Certificate => crate::cert::Certificate::decode(body).map(|_| ()),
101 102
        ObjectType::State => crate::objects::StateObject::decode(body).map(|_| ()),
102 103
    };
103 104
    r.map_err(|e| e.to_string())
…
364 unchanged lines
modified docs/spec.md +19 −0
…
701 unchanged lines
702 702
703 703
**SLSA provenance.** For tools outside Oxygen, `oxy receipt export --format slsa` writes what a valid receipt says as an in-toto Statement v1 with an SLSA provenance v1 predicate (`buildType` `https://oxygenhub.io/oxygenflow/job/v1`), in a DSSE envelope (payload type `application/vnd.in-toto+json`). Its subjects are the job's outputs, with the digest `oxygen-blob` (their ObjectRefs); a job with none has the state as its subject (`oxygen-state`). Resolved dependencies are the state, the scope (`oxygen-scope`) and the inputs; the builder is `urn:oxygen:runner:<runner KeyId>`; `startedOn` is the attestation's wall time less `duration_ms`, `finishedOn` that time. The signed receipt itself is a byproduct (`oxygen-receipt`, media type `application/vnd.oxygen.receipt+cbor`, base64 `content`), so an Oxygen verifier can still check the runner's signature. The envelope is signed by the **exporter**, with the Ed25519 half of its signing key over DSSE's pre-authentication encoding (which cannot be mistaken for an Oxygen signature message, §14.3); `keyid` is the KeyId in hex. DSSE verifiers know no ML-DSA, so this signature is classical: it vouches that the exporter checked the receipt, and the receipt inside keeps the runner's composite signature. `oxy identity export --pem` writes the signing key's Ed25519 public key as a PEM SubjectPublicKeyInfo, as cosign's `--key` takes it. A host does the same with its own key for the artifacts it keeps (hub spec).
704 704
705 +
### 14.7 Certificates for short-lived keys
706 +
707 +
An issuer (a hub's key) may vouch for a fresh key for a few minutes, so a Flow job or an agent signs with a key that dies with it instead of one kept on disk (oxygenhub#241, oxygenhub docs/keyless.md):
708 +
709 +
```
710 +
Certificate {
711 +
  version     [0]: 1
712 +
  key         [1]: bytes          // the short-lived key's identity object (§14.2)
713 +
  issuer      [2]: KeyId          // the key that signs this certificate
714 +
  subject     [3]: text           // who proved itself: "repo:OWNER/NAME:bookmark:B", "agent:NAME@ACCOUNT"
715 +
  claims      [4]: [[text, text]] // what else the proof said, sorted by name
716 +
  scope       [5]: [text]         // changes, refs, seal
717 +
  not_before  [6]: u64            // wall milliseconds
718 +
  expires     [7]: u64
719 +
}
720 +
```
721 +
722 +
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).
723 +
705 724
## 15. Transport
706 725
707 726
Every transport carries the same wire protocol (§16). The transport provides the channel; integrity and authenticity of history come from the object model regardless of channel.
…
339 unchanged lines