OXYGENHUB

oxy receipt export --format slsa: SLSA provenance cosign can check a39754d02c58

A valid receipt exports as an in-toto Statement v1 with an SLSA provenance
v1 predicate in a DSSE envelope, signed with the Ed25519 half of the
exporter's signing key. Subjects are the job's outputs by Oxygen hash (the
state when there are none); the signed receipt rides along as a byproduct,
so the runner's own composite signature can still be checked.
oxy identity export --pem writes the signing key as the PEM cosign's --key
takes. oxy-core's new dsse module holds PAE, the envelope, base64 and the
PEM; the hub's copy can move to it. Spec 14.6 and the guide say so; tests
check PAE against DSSE's example, the envelope against the right and wrong
keys, the PEM, and the CLI end to end.

Refs andymccall/oxygenhub#239
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 02:19 UTC
Signature
✔ signed by Claude Code
Hash
a39754d02c58821ead87a27f98ce909521dff56945c5b04f233247201f90fe32
Depends on
119bbf50cb86 1983686fb7a7 28fc23d7706a 4dcb8c0916b7 75167672ebcd 83e0677b07fe c6aa5bd9d1c8 d42369abf180 e3d071cecfe2 ee06a6c557b7 fc1a466a1e66 fce44ae9ad29 ff0af1ee299d
Depended on by
02e691881a4e 5f661562b3a4
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.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
24 atoms, 1 tree operation, 9 files

Browse the tree at this change

added crates/oxy-core/src/dsse.rs +151 −0
1 +
//! DSSE envelopes, the signature wrapper in-toto, SLSA and cosign read, and
2 +
//! the PEM public key cosign's `--key` takes: how Oxygen's attestations
3 +
//! reach tools outside it (spec §14.6, oxygenhub#239). Only the Ed25519 half
4 +
//! of a composite key signs here, since DSSE verifiers know Ed25519 and not
5 +
//! ML-DSA-65; DSSE's own pre-authentication encoding separates the domain
6 +
//! from Oxygen's signatures (which begin `oxy1 sig`).
7 +
8 +
use crate::identity::{Identity, SecretKey, VerifyError};
9 +
10 +
/// DSSE's payload type for in-toto statements.
11 +
pub const IN_TOTO: &str = "application/vnd.in-toto+json";
12 +
13 +
/// DSSE's pre-authentication encoding: what the signature covers.
14 +
pub fn pae(payload_type: &str, payload: &[u8]) -> Vec<u8> {
15 +
    let mut out = format!(
16 +
        "DSSEv1 {} {} {} ",
17 +
        payload_type.len(),
18 +
        payload_type,
19 +
        payload.len()
20 +
    )
21 +
    .into_bytes();
22 +
    out.extend_from_slice(payload);
23 +
    out
24 +
}
25 +
26 +
/// A DSSE envelope over `payload`, signed by `sk`, as JSON. `keyid` names
27 +
/// the key to a reader; DSSE gives it no meaning.
28 +
pub fn envelope(payload_type: &str, payload: &[u8], keyid: &str, sk: &SecretKey) -> String {
29 +
    let sig = sk.sign_ed25519(&pae(payload_type, payload));
30 +
    format!(
31 +
        "{{\"payloadType\":{},\"payload\":\"{}\",\"signatures\":[{{\"keyid\":{},\"sig\":\"{}\"}}]}}",
32 +
        crate::repo::json_str(payload_type),
33 +
        base64(payload),
34 +
        crate::repo::json_str(keyid),
35 +
        base64(&sig)
36 +
    )
37 +
}
38 +
39 +
/// Check one envelope signature, its parts already decoded.
40 +
pub fn verify(
41 +
    identity: &Identity,
42 +
    payload_type: &str,
43 +
    payload: &[u8],
44 +
    sig: &[u8],
45 +
) -> Result<(), VerifyError> {
46 +
    identity.verify_ed25519(&pae(payload_type, payload), sig)
47 +
}
48 +
49 +
/// The identity's Ed25519 public key as a PEM SubjectPublicKeyInfo.
50 +
pub fn public_pem(identity: &Identity) -> String {
51 +
    // SEQUENCE { SEQUENCE { OID 1.3.101.112 }, BIT STRING { key } }
52 +
    let mut der = vec![
53 +
        0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
54 +
    ];
55 +
    der.extend_from_slice(&identity.ed25519_pk);
56 +
    format!(
57 +
        "-----BEGIN PUBLIC KEY-----\n{}\n-----END PUBLIC KEY-----\n",
58 +
        base64(&der)
59 +
    )
60 +
}
61 +
62 +
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
63 +
64 +
/// Standard base64 with padding, as DSSE and PEM use.
65 +
pub fn base64(bytes: &[u8]) -> String {
66 +
    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
67 +
    for chunk in bytes.chunks(3) {
68 +
        let n = chunk.iter().fold(0u32, |acc, b| (acc << 8) | *b as u32) << (8 * (3 - chunk.len()));
69 +
        for i in 0..4 {
70 +
            if i <= chunk.len() {
71 +
                out.push(ALPHABET[((n >> (18 - 6 * i)) & 63) as usize] as char);
72 +
            } else {
73 +
                out.push('=');
74 +
            }
75 +
        }
76 +
    }
77 +
    out
78 +
}
79 +
80 +
/// [`base64`] back to bytes; `None` for anything that is not.
81 +
pub fn base64_decode(text: &str) -> Option<Vec<u8>> {
82 +
    let text = text.trim_end_matches('=');
83 +
    let mut out = Vec::with_capacity(text.len() * 3 / 4);
84 +
    let (mut acc, mut bits) = (0u32, 0);
85 +
    for c in text.bytes() {
86 +
        let v = ALPHABET.iter().position(|a| *a == c)? as u32;
87 +
        acc = (acc << 6) | v;
88 +
        bits += 6;
89 +
        if bits >= 8 {
90 +
            bits -= 8;
91 +
            out.push((acc >> bits) as u8);
92 +
            acc &= (1 << bits) - 1;
93 +
        }
94 +
    }
95 +
    Some(out)
96 +
}
97 +
98 +
#[cfg(test)]
99 +
mod tests {
100 +
    use super::*;
101 +
102 +
    #[test]
103 +
    fn pae_and_base64_are_the_specs() {
104 +
        // DSSE's own example: PAE("http://example.com/HelloWorld", "hello world").
105 +
        assert_eq!(
106 +
            pae("http://example.com/HelloWorld", b"hello world"),
107 +
            b"DSSEv1 29 http://example.com/HelloWorld 11 hello world".to_vec()
108 +
        );
109 +
        for (plain, coded) in [
110 +
            ("", ""),
111 +
            ("f", "Zg=="),
112 +
            ("fo", "Zm8="),
113 +
            ("foo", "Zm9v"),
114 +
            ("foobar", "Zm9vYmFy"),
115 +
        ] {
116 +
            assert_eq!(base64(plain.as_bytes()), coded);
117 +
            assert_eq!(base64_decode(coded).unwrap(), plain.as_bytes());
118 +
        }
119 +
        assert!(base64_decode("Zm9v!").is_none());
120 +
    }
121 +
122 +
    #[test]
123 +
    fn an_envelope_verifies_with_its_key_and_nothing_else() {
124 +
        let sk = SecretKey::generate();
125 +
        let id = sk.identity("Ada", "[email protected]", 1);
126 +
        let other = SecretKey::generate().identity("Eve", "[email protected]", 1);
127 +
        let payload = br#"{"_type":"https://in-toto.io/Statement/v1"}"#;
128 +
        let env = envelope(IN_TOTO, payload, "k", &sk);
129 +
        let sig_b64 = env
130 +
            .rsplit("\"sig\":\"")
131 +
            .next()
132 +
            .unwrap()
133 +
            .trim_end_matches("\"}]}");
134 +
        let sig = base64_decode(sig_b64).unwrap();
135 +
        assert!(env.contains(&format!("\"payload\":\"{}\"", base64(payload))));
136 +
        assert!(verify(&id, IN_TOTO, payload, &sig).is_ok());
137 +
        assert!(verify(&other, IN_TOTO, payload, &sig).is_err());
138 +
        assert!(verify(&id, IN_TOTO, b"{}", &sig).is_err());
139 +
        assert!(verify(&id, "text/plain", payload, &sig).is_err());
140 +
    }
141 +
142 +
    #[test]
143 +
    fn the_pem_holds_the_ed25519_key() {
144 +
        let id = SecretKey::generate().identity("Ada", "[email protected]", 1);
145 +
        let pem = public_pem(&id);
146 +
        let body: String = pem.lines().filter(|l| !l.starts_with("-----")).collect();
147 +
        let der = base64_decode(&body).unwrap();
148 +
        assert_eq!(der.len(), 44);
149 +
        assert_eq!(&der[12..], &id.ed25519_pk);
150 +
    }
151 +
}
modified crates/oxy-core/src/identity.rs +15 −0
…
135 unchanged lines
136 136
        }
137 137
    }
138 138
139 +
    /// Check a [`SecretKey::sign_ed25519`] signature by this identity.
140 +
    pub fn verify_ed25519(&self, msg: &[u8], sig: &[u8]) -> Result<(), VerifyError> {
141 +
        let sig = EdSignature::from_slice(sig).map_err(|_| VerifyError::Ed25519)?;
142 +
        self.ed_key()?
143 +
            .verify_strict(msg, &sig)
144 +
            .map_err(|_| VerifyError::Ed25519)
145 +
    }
146 +
139 147
    fn ed_key(&self) -> Result<EdVerifyingKey, VerifyError> {
140 148
        EdVerifyingKey::from_bytes(&self.ed25519_pk).map_err(|_| VerifyError::BadKey)
141 149
    }
…
178 unchanged lines
320 328
            previous: None,
321 329
            delegates: vec![],
322 330
        }
331 +
    }
332 +
333 +
    /// Ed25519 alone over `msg`, for a format that separates its own
334 +
    /// domain and whose verifiers know only Ed25519 (DSSE: see
335 +
    /// [`crate::dsse`]). Oxygen's own objects are signed with [`Self::sign`].
336 +
    pub fn sign_ed25519(&self, msg: &[u8]) -> [u8; 64] {
337 +
        self.ed().sign(msg).to_bytes()
323 338
    }
324 339
325 340
    pub fn sign(&self, domain: Domain, key: &KeyId, target: &ObjectRef) -> Signature {
…
225 unchanged lines
modified crates/oxy-core/src/lib.rs +1 −0
…
6 unchanged lines
7 7
pub mod cbor;
8 8
pub mod chunk;
9 9
pub mod diff;
10 +
pub mod dsse;
10 11
pub mod durable;
11 12
pub mod fasthash;
12 13
pub mod graph;
…
17 unchanged lines
modified docs/guide.md +8 −0
…
300 unchanged lines
301 301
oxy receipt export 3f2a9c build.receipt  # hand it to someone
302 302
oxy receipt verify build.receipt         # check one without keeping it
303 303
oxy receipt import build.receipt         # verify and keep it
304 +
oxy receipt export 3f2a9c prov.json --format slsa   # SLSA provenance, signed with your key
305 +
oxy identity export --pem me.pem         # the key cosign checks it with
304 306
```
305 307
308 +
The SLSA form is for tools outside Oxygen: in-toto and SLSA verifiers, and
309 +
`cosign verify-blob-attestation --key me.pem --type slsaprovenance1
310 +
--check-claims=false --signature prov.json`. Its subjects are named by
311 +
Oxygen hashes, which cosign cannot compute from a file, hence
312 +
`--check-claims=false`.
313 +
306 314
## 11. Big repositories: partial, sparse, shallow
307 315
308 316
```
…
148 unchanged lines
modified docs/spec.md +2 −0
…
694 unchanged lines
695 695
696 696
**Storage.** A repository keeps receipts beside the state they name: the receipt, the attestation with its signatures, and the runner's identity, indexed by state ObjectRef. Receipts live as long as the changes of their state, and keep their outputs and inputs alive (§21). `oxy receipt list|show|export|verify|import` reads and exchanges them; a receipt for another repository is refused.
697 697
698 +
**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).
699 +
698 700
## 15. Transport
699 701
700 702
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
modified src/cli/identity.rs +18 −5
…
350 unchanged lines
351 351
    Ok(0)
352 352
}
353 353
354 -
pub fn export(path: Option<PathBuf>) -> Result<i32> {
354 +
pub fn export(path: Option<PathBuf>, pem: bool) -> Result<i32> {
355 355
    let repo = open()?;
356 -
    let Some(key) = repo.config.author_key else {
356 +
    // The PEM is for checking what this home signs, so it is the signing
357 +
    // key's: a delegate's when `author.signer` names one.
358 +
    let key = if pem {
359 +
        repo.signing_key()
360 +
    } else {
361 +
        repo.config.author_key
362 +
    };
363 +
    let Some(key) = key else {
357 364
        anyhow::bail!("no identity configured; run `oxy identity create`");
358 365
    };
359 366
    let id = repo.identity(&key)?;
360 -
    let path = path.unwrap_or_else(|| PathBuf::from(format!("{}.identity", key.short(16))));
361 -
    std::fs::write(&path, id.encode())?;
367 +
    let ext = if pem { "pem" } else { "identity" };
368 +
    let path = path.unwrap_or_else(|| PathBuf::from(format!("{}.{ext}", key.short(16))));
369 +
    if pem {
370 +
        std::fs::write(&path, oxy_core::dsse::public_pem(&id))?;
371 +
    } else {
372 +
        std::fs::write(&path, id.encode())?;
373 +
    }
362 374
    println!(
363 -
        "{} identity {} <{}> ({}) to {}",
375 +
        "{} {} {} <{}> ({}) to {}",
364 376
        ui::done("Wrote"),
377 +
        if pem { "the public key of" } else { "identity" },
365 378
        ui::author(&id.name),
366 379
        id.email,
367 380
        key.short(12),
…
405 unchanged lines
modified src/cli/mod.rs +14 −3
…
614 unchanged lines
615 615
        /// Change, bookmark or tag whose state holds it
616 616
        #[arg(long)]
617 617
        rev: Option<String>,
618 +
        /// `oxygen`: the runner's signed receipt, as `verify` and `import`
619 +
        /// read it. `slsa`: SLSA provenance in a DSSE envelope, signed with
620 +
        /// your key, for in-toto and cosign (`oxy identity export --pem`
621 +
        /// gives the key they check it with)
622 +
        #[arg(long, default_value = "oxygen", value_parser = ["oxygen", "slsa"])]
623 +
        format: String,
618 624
    },
619 625
    /// Check a signed receipt in FILE without keeping it
620 626
    Verify {
…
93 unchanged lines
714 720
    Show,
715 721
    /// Write your identity object to a file, to hand to a policy holder
716 722
    Export {
717 -
        /// File to write (default: <key>.identity in the current directory)
723 +
        /// File to write (default: <key>.identity, or <key>.pem with --pem, in the current directory)
718 724
        path: Option<PathBuf>,
725 +
        /// Write the Ed25519 public key as PEM instead, as cosign's --key and
726 +
        /// other DSSE verifiers take it
727 +
        #[arg(long)]
728 +
        pem: bool,
719 729
    },
720 730
    /// Sign a host's challenge (hex, at least 16 bytes) to prove you hold your key; prints the signature as hex
721 731
    SignChallenge {
…
334 unchanged lines
1056 1066
                receipt: r,
1057 1067
                file,
1058 1068
                rev,
1059 -
            } => receipt::export(&r, &file, rev.as_deref()),
1069 +
                format,
1070 +
            } => receipt::export(&r, &file, rev.as_deref(), &format),
1060 1071
            ReceiptCmd::Verify { file, json } => receipt::verify(&file, json),
1061 1072
            ReceiptCmd::Import { file } => receipt::import(&file),
1062 1073
        },
…
37 unchanged lines
1100 1111
                expires,
1101 1112
            } => identity::create(name, email, local, expires),
1102 1113
            IdentityCmd::Show => identity::show(),
1103 -
            IdentityCmd::Export { path } => identity::export(path),
1114 +
            IdentityCmd::Export { path, pem } => identity::export(path, pem),
1104 1115
            IdentityCmd::SignChallenge { challenge, json } => {
1105 1116
                identity::sign_challenge(&challenge, json)
1106 1117
            }
…
99 unchanged lines
modified src/cli/receipt.rs +108 −2
…
172 unchanged lines
173 173
    Ok(if i.problem.is_some() { 1 } else { 0 })
174 174
}
175 175
176 -
/// `oxy receipt export HASH FILE`: the signed receipt, to hand to anyone.
177 -
pub fn export(prefix: &str, file: &str, rev: Option<&str>) -> Result<i32> {
176 +
/// `oxy receipt export HASH FILE [--format slsa]`: the signed receipt, to
177 +
/// hand to anyone, or SLSA provenance of it for tools outside Oxygen.
178 +
pub fn export(prefix: &str, file: &str, rev: Option<&str>, format: &str) -> Result<i32> {
178 179
    let repo = open()?;
179 180
    let i = find(&repo, prefix, rev)?;
181 +
    if format == "slsa" {
182 +
        return export_slsa(&repo, &i, file);
183 +
    }
180 184
    std::fs::write(file, i.signed.encode())?;
181 185
    println!(
182 186
        "{} {} {}",
183 187
        ui::done("Exported receipt"),
184 188
        ui::hash(format!("#{}", i.attest.short(12))),
185 189
        ui::path(file)
190 +
    );
191 +
    Ok(0)
192 +
}
193 +
194 +
/// What SLSA calls the build type of a Flow job.
195 +
pub const BUILD_TYPE: &str = "https://oxygenhub.io/oxygenflow/job/v1";
196 +
197 +
/// The in-toto Statement v1, with an SLSA provenance v1 predicate, of what
198 +
/// a receipt says (spec §14.6). Its subjects are the job's outputs (the
199 +
/// state it ran on when it made none), named by their Oxygen object hashes;
200 +
/// the signed receipt itself rides along as a byproduct, so an Oxygen
201 +
/// verifier can still check the runner's own signature.
202 +
pub fn statement(sr: &SignedReceipt) -> serde_json::Value {
203 +
    use serde_json::json;
204 +
    let r = &sr.receipt;
205 +
    let subject: Vec<_> = if r.outputs.is_empty() {
206 +
        vec![json!({ "name": "state", "digest": { "oxygen-state": sr.state().to_hex() } })]
207 +
    } else {
208 +
        r.outputs
209 +
            .iter()
210 +
            .map(|o| json!({ "name": o.to_hex(), "digest": { "oxygen-blob": o.to_hex() } }))
211 +
            .collect()
212 +
    };
213 +
    let rfc3339 = |ms: u64| super::format::when(ms).replacen(' ', "T", 1);
214 +
    let made = sr.attest.hlc.wall_ms;
215 +
    let mut deps = vec![
216 +
        json!({ "name": "state", "digest": { "oxygen-state": sr.state().to_hex() } }),
217 +
        json!({ "name": "scope", "digest": { "oxygen-scope": r.scope_tree_hash.to_hex() } }),
218 +
    ];
219 +
    deps.extend(
220 +
        r.inputs
221 +
            .iter()
222 +
            .map(|h| json!({ "name": h.to_hex(), "digest": { "oxygen-blob": h.to_hex() } })),
223 +
    );
224 +
    json!({
225 +
        "_type": "https://in-toto.io/Statement/v1",
226 +
        "subject": subject,
227 +
        "predicateType": "https://slsa.dev/provenance/v1",
228 +
        "predicate": {
229 +
            "buildDefinition": {
230 +
                "buildType": BUILD_TYPE,
231 +
                "externalParameters": {
232 +
                    "job": r.job_name,
233 +
                    "pipeline": r.pipeline_hash.to_hex(),
234 +
                },
235 +
                "internalParameters": {
236 +
                    "environment": r.environment.to_hex(),
237 +
                },
238 +
                "resolvedDependencies": deps,
239 +
            },
240 +
            "runDetails": {
241 +
                "builder": {
242 +
                    "id": format!("urn:oxygen:runner:{}", sr.runner.hash().to_hex()),
243 +
                    "version": { "name": sr.runner.name },
244 +
                },
245 +
                "metadata": {
246 +
                    "invocationId": sr.run(),
247 +
                    "startedOn": rfc3339(made.saturating_sub(r.duration_ms)),
248 +
                    "finishedOn": rfc3339(made),
249 +
                },
250 +
                "byproducts": [{
251 +
                    "name": "oxygen-receipt",
252 +
                    "mediaType": "application/vnd.oxygen.receipt+cbor",
253 +
                    "content": oxy_core::dsse::base64(&sr.encode()),
254 +
                    "annotations": {
255 +
                        "exitCode": r.exit_code,
256 +
                        "durationMs": r.duration_ms,
257 +
                        "runner": sr.runner.name,
258 +
                    },
259 +
                }],
260 +
            },
261 +
        },
262 +
    })
263 +
}
264 +
265 +
fn export_slsa(repo: &oxy_core::repo::Repo, i: &ReceiptInfo, file: &str) -> Result<i32> {
266 +
    if let Some(p) = &i.problem {
267 +
        bail!(
268 +
            "receipt #{} does not verify ({p}); provenance is only made of a valid one",
269 +
            i.attest.short(12)
270 +
        );
271 +
    }
272 +
    let Some((key, sk)) = repo.secret_key()? else {
273 +
        bail!("SLSA provenance is signed with your key, and none is set up here; run `oxy identity create`");
274 +
    };
275 +
    let payload = serde_json::to_vec(&statement(&i.signed))?;
276 +
    std::fs::write(
277 +
        file,
278 +
        oxy_core::dsse::envelope(oxy_core::dsse::IN_TOTO, &payload, &key.to_hex(), &sk),
279 +
    )?;
280 +
    println!(
281 +
        "{} {} {} {}",
282 +
        ui::done("Exported SLSA provenance of receipt"),
283 +
        ui::hash(format!("#{}", i.attest.short(12))),
284 +
        ui::path(file),
285 +
        ui::dim(format!("signed by {}", key.short(12)))
286 +
    );
287 +
    println!(
288 +
        "{}",
289 +
        ui::dim(format!(
290 +
            "  check it: oxy identity export --pem key.pem, then cosign verify-blob-attestation --key key.pem --type slsaprovenance1 --check-claims=false --signature {file}"
291 +
        ))
186 292
    );
187 293
    Ok(0)
188 294
}
…
33 unchanged lines
modified tests/cli_json.rs +50 −0
…
365 unchanged lines
366 366
    );
367 367
    assert_eq!(std::fs::read(&out).unwrap(), sr.encode());
368 368
369 +
    // SLSA provenance is signed with the exporter's key: none yet, so it is
370 +
    // refused and says what to do.
371 +
    let prov = tmp.path().join("prov.json");
372 +
    let p = prov.to_str().unwrap();
373 +
    let slsa = ["receipt", "export", &attest[..10], p, "--format", "slsa"];
374 +
    let (code, _, err) = oxy(&r, &home, &slsa);
375 +
    assert_ne!(code, 0);
376 +
    assert!(err.contains("oxy identity create"), "{err}");
377 +
    let ident = [
378 +
        "identity",
379 +
        "create",
380 +
        "--name",
381 +
        "Ada",
382 +
        "--email",
383 +
        "[email protected]",
384 +
    ];
385 +
    assert_eq!(oxy(&r, &home, &ident).0, 0);
386 +
    assert_eq!(oxy(&r, &home, &slsa).0, 0);
387 +
    let env: serde_json::Value = serde_json::from_slice(&std::fs::read(&prov).unwrap()).unwrap();
388 +
    assert_eq!(env["payloadType"], oxy_core::dsse::IN_TOTO);
389 +
    let payload = oxy_core::dsse::base64_decode(env["payload"].as_str().unwrap()).unwrap();
390 +
    let st: serde_json::Value = serde_json::from_slice(&payload).unwrap();
391 +
    assert_eq!(st["predicateType"], "https://slsa.dev/provenance/v1");
392 +
    assert_eq!(st["subject"][0]["digest"]["oxygen-state"], state.to_hex());
393 +
    let run = &st["predicate"]["runDetails"];
394 +
    assert_eq!(run["metadata"]["invocationId"], "demo#7");
395 +
    let kept = oxy_core::dsse::base64_decode(run["byproducts"][0]["content"].as_str().unwrap());
396 +
    assert_eq!(
397 +
        kept.unwrap(),
398 +
        sr.encode(),
399 +
        "the runner's receipt rides along"
400 +
    );
401 +
    // It verifies with the exporter's key and its PEM holds that key.
402 +
    let repo = oxy_core::repo::Repo::open(&r)
403 +
        .unwrap()
404 +
        .with_home(home.clone());
405 +
    let key = repo.signing_key().unwrap();
406 +
    assert_eq!(env["signatures"][0]["keyid"], key.to_hex());
407 +
    let me = repo.identity(&key).unwrap();
408 +
    let sig = oxy_core::dsse::base64_decode(env["signatures"][0]["sig"].as_str().unwrap()).unwrap();
409 +
    assert!(oxy_core::dsse::verify(&me, oxy_core::dsse::IN_TOTO, &payload, &sig).is_ok());
410 +
    assert!(oxy_core::dsse::verify(&runner, oxy_core::dsse::IN_TOTO, &payload, &sig).is_err());
411 +
    let pem = tmp.path().join("key.pem");
412 +
    let pem_args = ["identity", "export", "--pem", pem.to_str().unwrap()];
413 +
    assert_eq!(oxy(&r, &home, &pem_args).0, 0);
414 +
    assert_eq!(
415 +
        std::fs::read_to_string(&pem).unwrap(),
416 +
        oxy_core::dsse::public_pem(&me)
417 +
    );
418 +
369 419
    // A receipt whose result was changed after signing does not verify.
370 420
    let mut forged = sr.clone();
371 421
    forged.receipt.exit_code = 3;
…
8 unchanged lines