Skip to main content

basil_core/core/seal/
format.rs

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