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