Skip to main content

basil_core/core/seal/
format.rs

1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Sealed-bundle on-disk container (§2.2 of `designs/unlock-and-bundle.html`).
6//!
7//! Layout: a fixed ASCII magic + `u16` `format_version` (cleartext framing,
8//! checkable before any JSON parse), then a JSON body holding the header, the
9//! slot table, and the sealed payload. Binary fields are **base64 no-pad**
10//! (`URL_SAFE_NO_PAD`, the alphabet `basil-nats` uses).
11//!
12//! The **header is bound as AAD** over the payload AEAD and every slot's
13//! KEK-wrap. To sidestep JSON non-canonicalization the AAD is the **literal
14//! header bytes**: the header object is serialized once at seal time and those
15//! exact bytes are stored (base64-nopad) in `header_b64`; the opener feeds the
16//! decoded bytes straight to the AEAD and never re-serializes the header.
17
18use base64::Engine;
19use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
20use serde::{Deserialize, Serialize};
21
22use super::SealError;
23
24/// Fixed framing magic (9 bytes, includes the trailing NUL).
25pub const MAGIC: &[u8] = b"BASILBDL\x00";
26/// The only suite/format this build understands.
27pub const FORMAT_VERSION: u16 = 1;
28
29/// Container suite ids (§2.5). Refused if unknown so a future v2 can change a
30/// primitive without ambiguity.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32pub struct Suite {
33    /// Container encoding id.
34    pub container: ContainerId,
35    /// Payload AEAD id.
36    pub payload_aead: AeadId,
37    /// KEK-wrap AEAD id.
38    pub kek_wrap_aead: AeadId,
39}
40
41impl Suite {
42    /// The `format_version = 1` suite.
43    #[must_use]
44    pub const fn v1() -> Self {
45        Self {
46            container: ContainerId::Json,
47            payload_aead: AeadId::Aes256Gcm,
48            kek_wrap_aead: AeadId::Aes256Gcm,
49        }
50    }
51}
52
53/// Container encoding id.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum ContainerId {
57    /// ASCII magic + `u16` version + JSON body, binary fields base64-nopad.
58    Json,
59}
60
61/// AEAD primitive id.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename = "aes-256-gcm")]
64pub enum AeadId {
65    /// AES-256-GCM (12-byte nonce).
66    #[serde(rename = "aes-256-gcm")]
67    Aes256Gcm,
68}
69
70/// The header object. Its **exact serialized bytes** are the AAD.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct Header {
73    /// Must equal [`FORMAT_VERSION`].
74    pub format_version: u16,
75    /// Suite ids for this version.
76    pub suite: Suite,
77    /// 16-byte random bundle id (base64-nopad).
78    #[serde(with = "b64_array_16")]
79    pub bundle_id: [u8; 16],
80    /// Bundle creation time (unix seconds).
81    pub created_unix: u64,
82    /// Monotonic anti-rollback counter, bumped on every content change (§6.4).
83    pub epoch: u64,
84}
85
86impl Header {
87    /// Serialize the header to its canonical AAD bytes.
88    ///
89    /// # Errors
90    /// Returns [`SealError::Format`] if serialization fails (unexpected).
91    pub fn to_aad_bytes(&self) -> Result<Vec<u8>, SealError> {
92        serde_json::to_vec(self).map_err(|e| SealError::Format(format!("header serialize: {e}")))
93    }
94}
95
96/// A base64-nopad blob (nonce, ciphertext, salt) carried in JSON.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct B64Bytes(pub Vec<u8>);
99
100impl Serialize for B64Bytes {
101    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
102        s.serialize_str(&B64.encode(&self.0))
103    }
104}
105
106impl<'de> Deserialize<'de> for B64Bytes {
107    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
108        let s = String::deserialize(d)?;
109        B64.decode(s.as_bytes())
110            .map(Self)
111            .map_err(serde::de::Error::custom)
112    }
113}
114
115/// One unlock slot (§2.4). Wraps the same master KEK via a method-specific key.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct Slot {
118    /// Stable id for add/remove/CLI reference.
119    pub slot_id: u32,
120    /// Which unlock method backs this slot.
121    pub method: MethodKind,
122    /// Operator-facing label.
123    pub label: String,
124    /// Slot creation time (unix seconds).
125    pub created_unix: u64,
126    /// Non-secret method params needed to reconstruct the slot key.
127    pub params: MethodParams,
128    /// How the master KEK is wrapped for this method.
129    pub wrap: KekWrap,
130}
131
132/// Tagged unlock-method kind (§2.6). `Tpm` is reserved.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "kebab-case")]
135pub enum MethodKind {
136    /// age + age-plugin-yubikey (operator presence).
137    AgeYubikey,
138    /// 24-word BIP39 phrase → Argon2id (break-glass).
139    Bip39,
140    /// Passphrase read from a file and KDF'd with Argon2id.
141    Passphrase,
142    /// Reserved; fail-closed until the TPM slot lands.
143    Tpm,
144}
145
146impl std::fmt::Display for MethodKind {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        let s = match self {
149            Self::AgeYubikey => "age-yubikey",
150            Self::Bip39 => "bip39",
151            Self::Passphrase => "passphrase",
152            Self::Tpm => "tpm",
153        };
154        f.write_str(s)
155    }
156}
157
158/// Non-secret, method-specific public material (§2.4).
159#[derive(Debug, Clone, Serialize, Deserialize)]
160#[serde(tag = "kind", rename_all = "kebab-case")]
161pub enum MethodParams {
162    /// age recipient string (public).
163    AgeYubikey {
164        /// The age recipient the KEK is wrapped to.
165        recipient: String,
166    },
167    /// Argon2id salt + params for a phrase-derived slot key.
168    Bip39 {
169        /// 16-byte KDF salt (base64-nopad).
170        salt: B64Bytes,
171        /// Argon2id parameters.
172        argon2: Argon2Params,
173    },
174    /// Salt and KDF params for a file-sourced passphrase slot key.
175    Passphrase {
176        /// 16-byte KDF salt (base64-nopad).
177        salt: B64Bytes,
178        /// Argon2id parameters.
179        argon2: Argon2Params,
180    },
181    /// TPM2 sealed-object slot (feature `unlock-tpm`, §3.1 / §9).
182    ///
183    /// All fields are **non-secret** and safe at rest. The 32-byte slot key that
184    /// AES-256-GCM-wraps the master KEK (in [`KekWrap`]) is sealed *inside* the
185    /// TPM keyed-hash object; it never leaves the chip except transiently during
186    /// `TPM2_Unseal` under the matching PCR policy. `private` is encrypted to the
187    /// SRK and is meaningless off the originating TPM.
188    Tpm {
189        /// Marshalled `TPM2B_PUBLIC` of the sealed object (the keyed-hash public
190        /// area), base64-nopad.
191        public: B64Bytes,
192        /// Marshalled `TPM2B_PRIVATE` of the sealed object: SRK-encrypted,
193        /// base64-nopad. Safe at rest; only the originating TPM can load it.
194        private: B64Bytes,
195        /// The PCR selection the seal is bound to (`PolicyPCR`).
196        pcrs: TpmPcrSelection,
197        /// Object name hash algorithm, e.g. `"sha256"`.
198        name_alg: String,
199        /// Identifier of the fixed, deterministic SRK template used as the
200        /// parent, so recovery regenerates the identical primary key (e.g.
201        /// `"ecc-p256-srk-v1"`).
202        srk_template: String,
203    },
204}
205
206/// A TPM PCR selection: which bank and which PCR indices a seal is bound to.
207///
208/// Non-secret. Stored verbatim in a [`MethodParams::Tpm`] slot so recovery can
209/// rebuild the exact `TPML_PCR_SELECTION` used to compute the seal's
210/// `authPolicy`.
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212pub struct TpmPcrSelection {
213    /// PCR bank hash algorithm, e.g. `"sha256"`.
214    pub bank: String,
215    /// Selected PCR indices, ascending (e.g. `[0, 2, 4, 7]`).
216    pub pcrs: Vec<u32>,
217}
218
219/// Argon2id cost parameters (mem in KiB, time, parallelism) (§8.2).
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
221pub struct Argon2Params {
222    /// Memory cost in KiB.
223    pub m_cost_kib: u32,
224    /// Time cost (iterations).
225    pub t_cost: u32,
226    /// Parallelism.
227    pub p_cost: u32,
228}
229
230impl Argon2Params {
231    /// Ratified production profile: mem = 64 MiB, t = 3, p = 1 (§8.2).
232    pub const PRODUCTION: Self = Self {
233        m_cost_kib: 64 * 1024,
234        t_cost: 3,
235        p_cost: 1,
236    };
237}
238
239/// How a slot wraps the master KEK (§2.4).
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct KekWrap {
242    /// AEAD nonce (base64-nopad). For `age` slots this is unused/empty (age
243    /// manages its own framing).
244    pub nonce: B64Bytes,
245    /// Wrapped master KEK + tag (or the age stanza for the yubikey slot).
246    pub ciphertext: B64Bytes,
247}
248
249/// The sealed payload (§2.3): AES-256-GCM over the JSON cred map.
250#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct SealedPayload {
252    /// 12-byte AEAD nonce (base64-nopad).
253    pub nonce: B64Bytes,
254    /// Ciphertext + 16-byte GCM tag (base64-nopad).
255    pub ciphertext: B64Bytes,
256}
257
258/// One append-only credential deposit record.
259///
260/// The metadata is intentionally cleartext so operators can review pending
261/// records without an unlock secret. The credential itself is an X25519 sealed
262/// box and the signature authenticates the canonical deposit fields.
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct DepositRecord {
265    /// Backend id this credential should overlay after authorization.
266    pub backend_id: String,
267    /// Bundle epoch this deposit targets.
268    pub epoch: u64,
269    /// Monotonic sequence per `(contributor_key_id, backend_id)`.
270    pub seq: u64,
271    /// Sealed-payload contributor id.
272    pub contributor_key_id: String,
273    /// X25519 sealed credential.
274    pub sealed_cred: DepositSealedCred,
275    /// Ed25519 signature over [`deposit_signing_bytes`].
276    pub signature: B64Bytes,
277}
278
279/// X25519 sealed-box fields for a serialized `BackendCred`.
280#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct DepositSealedCred {
282    /// Sender ephemeral X25519 public key.
283    pub encapsulated_key: B64Bytes,
284    /// AEAD nonce.
285    pub nonce: B64Bytes,
286    /// Ciphertext plus tag.
287    pub ciphertext: B64Bytes,
288}
289
290/// The full JSON body (header + slots + payload).
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct BundleBody {
293    /// The **literal** header bytes used as AAD (base64-nopad). The opener feeds
294    /// these straight to the AEAD and never re-serializes the header.
295    pub header_b64: B64Bytes,
296    /// The parsed header (informational; AAD always comes from `header_b64`).
297    pub header: Header,
298    /// One entry per unlock method.
299    pub slots: Vec<Slot>,
300    /// The sealed cred map.
301    pub payload: SealedPayload,
302    /// Append-only credential deposits outside the payload AEAD.
303    #[serde(default)]
304    pub deposits: Vec<DepositRecord>,
305}
306
307/// A parsed bundle file: framing + body.
308#[derive(Debug, Clone)]
309pub struct ParsedBundle {
310    /// The JSON body.
311    pub body: BundleBody,
312}
313
314impl ParsedBundle {
315    /// The AAD = literal on-disk header bytes (decoded `header_b64`).
316    #[must_use]
317    pub fn header_aad(&self) -> &[u8] {
318        &self.body.header_b64.0
319    }
320}
321
322/// Encode a full bundle file (framing prefix + JSON body).
323///
324/// `header_aad` MUST be the exact bytes returned by [`Header::to_aad_bytes`] for
325/// `header`: the same bytes the payload/slots were sealed under.
326///
327/// # Errors
328/// Returns [`SealError::Format`] on JSON serialization failure.
329pub fn encode(
330    header: &Header,
331    header_aad: &[u8],
332    slots: Vec<Slot>,
333    payload: SealedPayload,
334) -> Result<Vec<u8>, SealError> {
335    encode_with_deposits(header, header_aad, slots, payload, Vec::new())
336}
337
338/// Encode a full bundle file while preserving or setting the deposit log.
339///
340/// # Errors
341/// Returns [`SealError::Format`] on JSON serialization failure.
342pub fn encode_with_deposits(
343    header: &Header,
344    header_aad: &[u8],
345    slots: Vec<Slot>,
346    payload: SealedPayload,
347    deposits: Vec<DepositRecord>,
348) -> Result<Vec<u8>, SealError> {
349    let body = BundleBody {
350        header_b64: B64Bytes(header_aad.to_vec()),
351        header: header.clone(),
352        slots,
353        payload,
354        deposits,
355    };
356    let body_json =
357        serde_json::to_vec(&body).map_err(|e| SealError::Format(format!("body serialize: {e}")))?;
358
359    let mut out = Vec::with_capacity(MAGIC.len() + 2 + body_json.len());
360    out.extend_from_slice(MAGIC);
361    out.extend_from_slice(&FORMAT_VERSION.to_be_bytes());
362    out.extend_from_slice(&body_json);
363    Ok(out)
364}
365
366/// Canonical bytes signed by a deposit contributor.
367///
368/// The `sealed_cred` field is signed as the exact serialized X25519 envelope
369/// fields, but the enclosing signature is omitted. `serde_json` emits struct
370/// fields in declaration order, giving us stable bytes for this v1 JSON
371/// container without relying on map ordering.
372///
373/// # Errors
374/// Returns [`SealError::Format`] on JSON serialization failure.
375pub fn deposit_signing_bytes(record: &DepositRecord) -> Result<Vec<u8>, SealError> {
376    #[derive(Serialize)]
377    struct Signed<'a> {
378        backend_id: &'a str,
379        epoch: u64,
380        seq: u64,
381        contributor_key_id: &'a str,
382        sealed_cred: &'a DepositSealedCred,
383    }
384
385    serde_json::to_vec(&Signed {
386        backend_id: &record.backend_id,
387        epoch: record.epoch,
388        seq: record.seq,
389        contributor_key_id: &record.contributor_key_id,
390        sealed_cred: &record.sealed_cred,
391    })
392    .map_err(|e| SealError::Format(format!("deposit canonical serialize: {e}")))
393}
394
395/// Decode + validate a bundle file: check magic + version *before* JSON parse,
396/// then parse the body and verify the embedded header round-trips.
397///
398/// # Errors
399/// Returns [`SealError::Format`] for a bad magic / unknown version / malformed
400/// JSON / mismatched header, all fail-closed (no panic, §1.3).
401pub fn decode(bytes: &[u8]) -> Result<ParsedBundle, SealError> {
402    let rest = bytes
403        .strip_prefix(MAGIC)
404        .ok_or_else(|| SealError::Format("bad magic (not a sealed bundle)".into()))?;
405    let (ver_bytes, body_bytes) = rest
406        .split_at_checked(2)
407        .ok_or_else(|| SealError::Format("truncated version field".into()))?;
408    let version = u16::from_be_bytes(
409        <[u8; 2]>::try_from(ver_bytes)
410            .map_err(|_| SealError::Format("truncated version field".into()))?,
411    );
412    if version != FORMAT_VERSION {
413        return Err(SealError::Format(format!(
414            "unsupported format_version {version} (this build understands {FORMAT_VERSION})"
415        )));
416    }
417
418    let body: BundleBody = serde_json::from_slice(body_bytes)
419        .map_err(|e| SealError::Format(format!("body parse: {e}")))?;
420
421    // The header inside the body must match the AAD bytes that were embedded:
422    // a mismatch means the file was edited inconsistently. We compare the
423    // re-parsed header from the AAD bytes to the structured `header` field; the
424    // AAD always remains the literal `header_b64` bytes for the AEAD.
425    let header_from_aad: Header = serde_json::from_slice(&body.header_b64.0)
426        .map_err(|e| SealError::Format(format!("header (aad) parse: {e}")))?;
427    if header_from_aad != body.header {
428        return Err(SealError::Format(
429            "header / header_b64 mismatch (tampered container)".into(),
430        ));
431    }
432    if body.header.format_version != FORMAT_VERSION {
433        return Err(SealError::Format(
434            "header.format_version disagrees with framing".into(),
435        ));
436    }
437    if body.header.suite != Suite::v1() {
438        return Err(SealError::Format("unknown suite ids".into()));
439    }
440
441    Ok(ParsedBundle { body })
442}
443
444/// serde helper: a `[u8; 16]` as a base64-nopad string.
445mod b64_array_16 {
446    use super::B64;
447    use base64::Engine;
448    use serde::{Deserialize, Deserializer, Serializer};
449
450    pub fn serialize<S: Serializer>(v: &[u8; 16], s: S) -> Result<S::Ok, S::Error> {
451        s.serialize_str(&B64.encode(v))
452    }
453
454    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 16], D::Error> {
455        let s = String::deserialize(d)?;
456        let v = B64.decode(s.as_bytes()).map_err(serde::de::Error::custom)?;
457        <[u8; 16]>::try_from(v.as_slice())
458            .map_err(|_| serde::de::Error::custom("bundle_id must be 16 bytes"))
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    fn sample_header() -> Header {
467        Header {
468            format_version: FORMAT_VERSION,
469            suite: Suite::v1(),
470            bundle_id: [7u8; 16],
471            created_unix: 1_700_000_000,
472            epoch: 1,
473        }
474    }
475
476    #[test]
477    fn encode_decode_round_trip() {
478        let header = sample_header();
479        let aad = header.to_aad_bytes().unwrap();
480        let payload = SealedPayload {
481            nonce: B64Bytes(vec![1; 12]),
482            ciphertext: B64Bytes(vec![2; 48]),
483        };
484        let file = encode(&header, &aad, vec![], payload).unwrap();
485        assert!(file.starts_with(MAGIC));
486        let parsed = decode(&file).unwrap();
487        assert_eq!(parsed.body.header, header);
488        assert_eq!(parsed.header_aad(), aad.as_slice());
489    }
490
491    #[test]
492    fn bad_magic_fails() {
493        let err = decode(b"not a bundle at all").unwrap_err();
494        assert!(matches!(err, SealError::Format(_)));
495    }
496
497    #[test]
498    fn wrong_version_fails_before_parse() {
499        let mut file = Vec::new();
500        file.extend_from_slice(MAGIC);
501        file.extend_from_slice(&2u16.to_be_bytes());
502        file.extend_from_slice(b"{}");
503        let err = decode(&file).unwrap_err();
504        assert!(matches!(err, SealError::Format(m) if m.contains("unsupported format_version")));
505    }
506
507    #[test]
508    fn aead_id_serializes_to_spec_string() {
509        let v = serde_json::to_value(AeadId::Aes256Gcm).unwrap();
510        assert_eq!(v, serde_json::json!("aes-256-gcm"));
511    }
512}