Skip to main content

tf_types/
bundle.rs

1//! Encrypted .tfbundle (L4/L5) sealing + transparency anchoring — Rust
2//! mirror of `tools/tf-types-ts/src/core/bundle.ts`.
3
4use crate::encoding::STANDARD;
5use chacha20poly1305::aead::{Aead, KeyInit};
6use chacha20poly1305::{ChaCha20Poly1305, Nonce};
7use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
8use hkdf::Hkdf;
9use rand::RngCore;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use sha2::{Digest, Sha256};
13use x25519_dalek::{PublicKey as X25519Public, StaticSecret as X25519Secret};
14
15use crate::canonicalize;
16
17const HKDF_INFO: &[u8] = b"tfbundle/wrap";
18
19#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
20pub struct EncryptedProofBundle {
21    pub bundle_version: String,
22    pub level: String,
23    pub ciphertext: String,
24    pub nonce: String,
25    pub wrapped_keys: Vec<WrappedKey>,
26    #[serde(skip_serializing_if = "Option::is_none", default)]
27    pub transparency_anchor: Option<Value>,
28    pub signature: SignatureEnvelope,
29}
30
31#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
32pub struct WrappedKey {
33    pub recipient: String,
34    #[serde(skip_serializing_if = "Option::is_none", default)]
35    pub recipient_key_id: Option<String>,
36    pub ephemeral_public: String,
37    pub wrapped: String,
38    pub wrap_nonce: String,
39}
40
41#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
42pub struct SignatureEnvelope {
43    pub algorithm: String,
44    pub signer: String,
45    pub signature: String,
46}
47
48#[derive(Clone, Debug)]
49pub struct BundleRecipient {
50    pub actor: String,
51    pub kem_public: [u8; 32],
52    pub key_id: Option<String>,
53}
54
55pub fn seal_bundle(
56    bundle: &Value,
57    recipients: &[BundleRecipient],
58    level: &str,
59    signer_priv: &[u8; 32],
60    signer: &str,
61) -> EncryptedProofBundle {
62    let mut rng = rand::thread_rng();
63    let mut data_key = [0u8; 32];
64    rng.fill_bytes(&mut data_key);
65    let mut nonce_bytes = [0u8; 12];
66    rng.fill_bytes(&mut nonce_bytes);
67    let cipher = ChaCha20Poly1305::new(&data_key.into());
68    let plaintext = canonicalize(bundle).unwrap_or_default();
69    let ciphertext = cipher
70        .encrypt(Nonce::from_slice(&nonce_bytes), plaintext.as_bytes())
71        .expect("seal");
72
73    let mut wrapped_keys = Vec::with_capacity(recipients.len());
74    for r in recipients {
75        let mut eph_seed = [0u8; 32];
76        rng.fill_bytes(&mut eph_seed);
77        let eph = X25519Secret::from(eph_seed);
78        let eph_pub = X25519Public::from(&eph);
79        let recipient_pub = X25519Public::from(r.kem_public);
80        let shared = eph.diffie_hellman(&recipient_pub);
81        let hk = Hkdf::<Sha256>::new(None, shared.as_bytes());
82        let mut wrap_key = [0u8; 32];
83        hk.expand(HKDF_INFO, &mut wrap_key).expect("hkdf");
84        let mut wrap_nonce = [0u8; 12];
85        rng.fill_bytes(&mut wrap_nonce);
86        let wrap_cipher = ChaCha20Poly1305::new(&wrap_key.into());
87        let wrapped = wrap_cipher
88            .encrypt(Nonce::from_slice(&wrap_nonce), data_key.as_ref())
89            .expect("wrap");
90        wrapped_keys.push(WrappedKey {
91            recipient: r.actor.clone(),
92            recipient_key_id: r.key_id.clone(),
93            ephemeral_public: STANDARD.encode(eph_pub.as_bytes()),
94            wrapped: STANDARD.encode(&wrapped),
95            wrap_nonce: STANDARD.encode(wrap_nonce),
96        });
97    }
98
99    let mut stub = EncryptedProofBundle {
100        bundle_version: "1".into(),
101        level: level.into(),
102        ciphertext: STANDARD.encode(&ciphertext),
103        nonce: STANDARD.encode(nonce_bytes),
104        wrapped_keys,
105        transparency_anchor: None,
106        signature: SignatureEnvelope {
107            algorithm: "ed25519".into(),
108            signer: signer.into(),
109            signature: String::new(),
110        },
111    };
112    let digest = encrypted_signing_bytes(&stub);
113    let signing = SigningKey::from_bytes(signer_priv);
114    let sig: Signature = signing.sign(&digest);
115    stub.signature.signature = STANDARD.encode(sig.to_bytes());
116    stub
117}
118
119pub fn open_bundle(
120    enc: &EncryptedProofBundle,
121    recipient_priv: &[u8; 32],
122    recipient_actor: &str,
123    signer_pub: Option<&[u8; 32]>,
124) -> Result<Value, String> {
125    let wrap = enc
126        .wrapped_keys
127        .iter()
128        .find(|w| w.recipient == recipient_actor)
129        .ok_or_else(|| format!("no wrapped key for recipient {}", recipient_actor))?;
130    let eph_pub_bytes = STANDARD
131        .decode(&wrap.ephemeral_public)
132        .map_err(|e| format!("ephemeral_public base64: {}", e))?;
133    let mut eph_pub_arr = [0u8; 32];
134    if eph_pub_bytes.len() != 32 {
135        return Err("ephemeral_public not 32 bytes".into());
136    }
137    eph_pub_arr.copy_from_slice(&eph_pub_bytes);
138    let recipient_secret = X25519Secret::from(*recipient_priv);
139    let shared = recipient_secret.diffie_hellman(&X25519Public::from(eph_pub_arr));
140    let hk = Hkdf::<Sha256>::new(None, shared.as_bytes());
141    let mut wrap_key = [0u8; 32];
142    hk.expand(HKDF_INFO, &mut wrap_key)
143        .map_err(|e| e.to_string())?;
144    let wrapped = STANDARD
145        .decode(&wrap.wrapped)
146        .map_err(|e| format!("wrapped base64: {}", e))?;
147    let wrap_nonce = STANDARD
148        .decode(&wrap.wrap_nonce)
149        .map_err(|e| format!("wrap_nonce base64: {}", e))?;
150    let data_key_bytes = ChaCha20Poly1305::new(&wrap_key.into())
151        .decrypt(Nonce::from_slice(&wrap_nonce), wrapped.as_ref())
152        .map_err(|e| format!("unwrap: {}", e))?;
153    let ciphertext = STANDARD
154        .decode(&enc.ciphertext)
155        .map_err(|e| format!("ciphertext base64: {}", e))?;
156    let nonce = STANDARD
157        .decode(&enc.nonce)
158        .map_err(|e| format!("nonce base64: {}", e))?;
159    let mut data_key_arr = [0u8; 32];
160    if data_key_bytes.len() != 32 {
161        return Err("data_key not 32 bytes".into());
162    }
163    data_key_arr.copy_from_slice(&data_key_bytes);
164    let plaintext = ChaCha20Poly1305::new(&data_key_arr.into())
165        .decrypt(Nonce::from_slice(&nonce), ciphertext.as_ref())
166        .map_err(|e| format!("decrypt: {}", e))?;
167
168    if let Some(pk) = signer_pub {
169        let digest = encrypted_signing_bytes(enc);
170        let sig_bytes = STANDARD
171            .decode(&enc.signature.signature)
172            .map_err(|e| format!("signature base64: {}", e))?;
173        let sig = Signature::from_slice(&sig_bytes).map_err(|e| format!("sig parse: {}", e))?;
174        let vk = VerifyingKey::from_bytes(pk).map_err(|e| format!("verifying key: {}", e))?;
175        if vk.verify(&digest, &sig).is_err() {
176            return Err("encrypted bundle signature did not verify".into());
177        }
178    }
179
180    let json: Value = serde_json::from_slice(&plaintext).map_err(|e| e.to_string())?;
181    Ok(json)
182}
183
184pub fn encrypted_signing_bytes(enc: &EncryptedProofBundle) -> [u8; 32] {
185    let mut value = serde_json::to_value(enc).unwrap_or(Value::Null);
186    if let Value::Object(map) = &mut value {
187        map.remove("signature");
188    }
189    let canonical = canonicalize(&value).unwrap_or_default();
190    Sha256::digest(canonical.as_bytes()).into()
191}
192
193/// Build the DER-encoded TimeStampReq for SHA-256 over `data` (RFC 3161 §2.4.1).
194pub fn build_rfc3161_request(data: &[u8]) -> Vec<u8> {
195    let digest: [u8; 32] = Sha256::digest(data).into();
196    let oid_sha256: [u8; 11] = [
197        0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01,
198    ];
199    let alg_id = der_sequence(&[oid_sha256.to_vec(), vec![0x05, 0x00]]);
200    let hashed_message = der_octet_string(&digest);
201    let message_imprint = der_sequence(&[alg_id, hashed_message]);
202    let version = der_integer(&[0x01]);
203    let cert_req = vec![0x01, 0x01, 0xff];
204    der_sequence(&[version, message_imprint, cert_req])
205}
206
207fn der_sequence(parts: &[Vec<u8>]) -> Vec<u8> {
208    let body: Vec<u8> = parts.iter().flat_map(|p| p.clone()).collect();
209    let mut out = Vec::with_capacity(2 + body.len());
210    out.push(0x30);
211    out.extend_from_slice(&der_len(body.len()));
212    out.extend_from_slice(&body);
213    out
214}
215
216fn der_octet_string(bytes: &[u8]) -> Vec<u8> {
217    let mut out = Vec::with_capacity(2 + bytes.len());
218    out.push(0x04);
219    out.extend_from_slice(&der_len(bytes.len()));
220    out.extend_from_slice(bytes);
221    out
222}
223
224fn der_integer(bytes: &[u8]) -> Vec<u8> {
225    let mut start = 0usize;
226    while start < bytes.len() - 1 && bytes[start] == 0 {
227        start += 1;
228    }
229    let payload = &bytes[start..];
230    let needs_pad = payload[0] & 0x80 != 0;
231    let len = payload.len() + if needs_pad { 1 } else { 0 };
232    let mut out = Vec::with_capacity(2 + len);
233    out.push(0x02);
234    out.extend_from_slice(&der_len(len));
235    if needs_pad {
236        out.push(0x00);
237    }
238    out.extend_from_slice(payload);
239    out
240}
241
242fn der_len(n: usize) -> Vec<u8> {
243    if n < 0x80 {
244        return vec![n as u8];
245    }
246    let mut bytes = Vec::new();
247    let mut v = n;
248    while v > 0 {
249        bytes.insert(0, (v & 0xff) as u8);
250        v >>= 8;
251    }
252    let mut out = Vec::with_capacity(1 + bytes.len());
253    out.push(0x80 | bytes.len() as u8);
254    out.extend_from_slice(&bytes);
255    out
256}
257
258/// Anchor backend trait — every anchor kind exposes a `submit` that
259/// produces a JSON inclusion proof and a `verify` that re-checks the
260/// proof against the bundle bytes. Mirror of TS `AnchorBackend`.
261pub trait AnchorBackend: Send + Sync {
262    fn kind(&self) -> &'static str;
263    fn submit(&self, bundle_bytes: &[u8]) -> Result<Value, String>;
264    fn verify(&self, bundle_bytes: &[u8], inclusion_proof: &Value) -> bool;
265}
266
267/// RFC 6962 Certificate Transparency add-pre-chain submission. Stub-
268/// equivalent of the TS submitToRfc6962: posts the bundle digest as
269/// the "leaf" of a single-cert chain. The CT log returns an SCT
270/// (signed certificate timestamp) that we record as the inclusion
271/// proof. Real production deployments configure a list of trusted
272/// log URLs + their public keys.
273#[cfg(feature = "http-anchors")]
274pub struct Rfc6962Anchor {
275    pub log_url: String,
276}
277
278#[cfg(feature = "http-anchors")]
279impl Rfc6962Anchor {
280    pub fn new(log_url: impl Into<String>) -> Self {
281        Self {
282            log_url: log_url.into(),
283        }
284    }
285}
286
287#[cfg(feature = "http-anchors")]
288impl AnchorBackend for Rfc6962Anchor {
289    fn kind(&self) -> &'static str {
290        "rfc6962"
291    }
292    fn submit(&self, bundle_bytes: &[u8]) -> Result<Value, String> {
293        // Minimal CT-add-chain payload: a single base64'd "cert" whose
294        // payload is the bundle digest. Production code would project
295        // bundle bytes through a real cert template; v0.1.0 ships the
296        // hand-shaped payload that matches what existing CT logs
297        // tolerate for transparency-only entries.
298        let digest = Sha256::digest(bundle_bytes);
299        let cert_b64 = crate::crypto::b64encode(&digest);
300        let body = serde_json::json!({ "chain": [cert_b64] });
301        let url = format!("{}/ct/v1/add-chain", self.log_url.trim_end_matches('/'));
302        let resp = ureq::post(&url)
303            .set("content-type", "application/json")
304            .send_json(body)
305            .map_err(|e| format!("rfc6962 submit: {e}"))?;
306        let json: Value = resp
307            .into_json()
308            .map_err(|e| format!("rfc6962 response parse: {e}"))?;
309        Ok(serde_json::json!({
310            "kind": "rfc6962",
311            "log_url": self.log_url,
312            "sct": json,
313            "digest_hex": digest.iter().map(|b| format!("{:02x}", b)).collect::<String>(),
314        }))
315    }
316    fn verify(&self, bundle_bytes: &[u8], inclusion_proof: &Value) -> bool {
317        let digest = Sha256::digest(bundle_bytes);
318        let want: String = digest.iter().map(|b| format!("{:02x}", b)).collect();
319        inclusion_proof
320            .get("digest_hex")
321            .and_then(|v| v.as_str())
322            .map(|s| s == want)
323            .unwrap_or(false)
324    }
325}
326
327/// Sigstore Rekor v2 entry submission. Posts a hashedrekord-style
328/// payload with the bundle digest. The returned `logIndex` and
329/// `verification` proof are recorded as the inclusion record.
330#[cfg(feature = "http-anchors")]
331pub struct SigstoreAnchor {
332    pub rekor_url: String,
333}
334
335#[cfg(feature = "http-anchors")]
336impl SigstoreAnchor {
337    pub fn new(rekor_url: impl Into<String>) -> Self {
338        Self {
339            rekor_url: rekor_url.into(),
340        }
341    }
342}
343
344#[cfg(feature = "http-anchors")]
345impl AnchorBackend for SigstoreAnchor {
346    fn kind(&self) -> &'static str {
347        "sigstore"
348    }
349    fn submit(&self, bundle_bytes: &[u8]) -> Result<Value, String> {
350        let digest = Sha256::digest(bundle_bytes);
351        let hex: String = digest.iter().map(|b| format!("{:02x}", b)).collect();
352        // Hashedrekord v0.0.1: just need the hash + a dummy signature.
353        let body = serde_json::json!({
354            "apiVersion": "0.0.1",
355            "kind": "hashedrekord",
356            "spec": {
357                "data": { "hash": { "algorithm": "sha256", "value": hex } },
358                "signature": { "format": "x509" },
359            },
360        });
361        let url = format!(
362            "{}/api/v1/log/entries",
363            self.rekor_url.trim_end_matches('/')
364        );
365        let resp = ureq::post(&url)
366            .set("content-type", "application/json")
367            .send_json(body)
368            .map_err(|e| format!("sigstore submit: {e}"))?;
369        let json: Value = resp
370            .into_json()
371            .map_err(|e| format!("sigstore response parse: {e}"))?;
372        Ok(serde_json::json!({
373            "kind": "sigstore",
374            "rekor_url": self.rekor_url,
375            "entry": json,
376            "digest_hex": digest.iter().map(|b| format!("{:02x}", b)).collect::<String>(),
377        }))
378    }
379    fn verify(&self, bundle_bytes: &[u8], inclusion_proof: &Value) -> bool {
380        let digest = Sha256::digest(bundle_bytes);
381        let want: String = digest.iter().map(|b| format!("{:02x}", b)).collect();
382        inclusion_proof
383            .get("digest_hex")
384            .and_then(|v| v.as_str())
385            .map(|s| s == want)
386            .unwrap_or(false)
387    }
388}
389
390/// In-memory transparency anchor for tests.
391#[derive(Default)]
392pub struct MemoryAnchor {
393    entries: std::sync::Mutex<std::collections::HashMap<String, usize>>,
394}
395
396impl MemoryAnchor {
397    pub fn new() -> Self {
398        Self::default()
399    }
400
401    pub fn submit(&self, bundle_bytes: &[u8]) -> Value {
402        let digest = Sha256::digest(bundle_bytes);
403        let hex: String = digest.iter().map(|b| format!("{:02x}", b)).collect();
404        let mut entries = self.entries.lock().unwrap();
405        let seq = entries.len();
406        entries.insert(hex.clone(), seq);
407        serde_json::json!({ "kind": "memory", "digest": hex, "sequence_number": seq })
408    }
409
410    pub fn verify_inclusion(&self, bundle_bytes: &[u8], inclusion_proof: &Value) -> bool {
411        let digest = Sha256::digest(bundle_bytes);
412        let hex: String = digest.iter().map(|b| format!("{:02x}", b)).collect();
413        if inclusion_proof.get("digest").and_then(|v| v.as_str()) != Some(&hex) {
414            return false;
415        }
416        let seq = inclusion_proof
417            .get("sequence_number")
418            .and_then(|v| v.as_u64())
419            .map(|n| n as usize);
420        let entries = self.entries.lock().unwrap();
421        seq == entries.get(&hex).copied()
422    }
423}
424
425impl AnchorBackend for MemoryAnchor {
426    fn kind(&self) -> &'static str {
427        "memory"
428    }
429    fn submit(&self, bundle_bytes: &[u8]) -> Result<Value, String> {
430        Ok(MemoryAnchor::submit(self, bundle_bytes))
431    }
432    fn verify(&self, bundle_bytes: &[u8], inclusion_proof: &Value) -> bool {
433        MemoryAnchor::verify_inclusion(self, bundle_bytes, inclusion_proof)
434    }
435}