Skip to main content

basil_core/core/seal/
mod.rs

1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Sealed-bundle seal / open / slot management (vault-vh1).
6//!
7//! Implements `designs/unlock-and-bundle.html`: a `0600` multi-slot sealed bundle
8//! holding the broker's own bootstrap credentials. One 32-byte master KEK
9//! AES-256-GCM-encrypts the payload (the [`CredBundle`] cred map, §4); *N*
10//! independent [`Slot`]s each wrap that *same* KEK via a different
11//! [`UnlockMethod`]. Adding/removing a method rewraps one slot and leaves the
12//! payload untouched. The header is bound as AAD over the payload AEAD and every
13//! slot wrap (anti-downgrade/splice, §2.2).
14//!
15//! Everything here fails **closed** and never panics on a runtime path (§1.3):
16//! every fallible step returns a `Result`, the master KEK and the decrypted
17//! [`CredBundle`] are `Zeroizing`, and the KEK is wiped as early as possible.
18
19pub mod aead;
20pub mod cred;
21pub mod deposit;
22pub mod format;
23pub mod unlock;
24
25use zeroize::Zeroizing;
26
27pub use cred::{BackendCred, CRED_SCHEMA_VERSION, CredBundle, DepositContributor};
28pub use deposit::{
29    DepositAction, DepositReview, DepositStatus, apply_authorized_deposits,
30    contributor_public_token, create_signed_record, promote_deposits, public_key_from_token,
31    public_key_token, review_deposits,
32};
33pub use format::{
34    Argon2Params, B64Bytes, BundleBody, DepositRecord, DepositSealedCred, FORMAT_VERSION, Header,
35    KekWrap, MAGIC, MethodKind, MethodParams, ParsedBundle, SealedPayload, Slot, Suite,
36};
37pub use unlock::{UnlockError, UnlockMethod};
38
39#[cfg(feature = "unlock-age-yubikey")]
40pub use unlock::age_yubikey::AgeYubikeyMethod;
41#[cfg(feature = "unlock-bip39")]
42pub use unlock::bip39::Bip39Method;
43pub use unlock::passphrase::PassphraseMethod;
44pub use unlock::tpm::TpmMethod;
45
46/// Errors from sealing/opening a bundle (§5). Distinct from [`UnlockError`],
47/// which is per-method; this is the container/orchestration layer.
48#[derive(Debug, thiserror::Error)]
49pub enum SealError {
50    /// Container framing / JSON / version problem (bad magic, unknown version).
51    #[error("bundle format: {0}")]
52    Format(String),
53
54    /// AEAD authentication failed (tampered header/payload, wrong KEK).
55    #[error("authentication failed (tampered or wrong key)")]
56    AuthFailed,
57
58    /// A crypto/serialization failure.
59    #[error("crypto: {0}")]
60    Crypto(String),
61
62    /// No enabled+available slot could open the bundle. Fails closed.
63    #[error("no unlock slot could open the bundle")]
64    NoSlotOpened,
65
66    /// Would remove the last slot (bricks the bundle).
67    #[error("refusing to remove the last slot")]
68    LastSlot,
69
70    /// The referenced slot id is not present.
71    #[error("slot {0} not found")]
72    SlotNotFound(u32),
73
74    /// A method-level unlock error surfaced during seal/open.
75    #[error(transparent)]
76    Unlock(#[from] UnlockError),
77
78    /// A serde error during payload (de)serialization.
79    #[error("payload encode/decode: {0}")]
80    Payload(String),
81}
82
83/// 32-byte master KEK that zeroizes on drop (the newtype the §3 trait sketch
84/// calls `KeyMaterial`; renamed to avoid colliding with `proto::KeyMaterial`).
85pub struct MasterKek(Zeroizing<[u8; aead::KEY_LEN]>);
86
87impl MasterKek {
88    /// Draw a fresh master KEK from the OS CSPRNG.
89    #[must_use]
90    pub fn generate() -> Self {
91        Self(aead::fresh_key())
92    }
93
94    /// Build from exactly 32 bytes, or `None` if the length is wrong.
95    #[must_use]
96    pub fn from_slice(bytes: &[u8]) -> Option<Self> {
97        let arr = <[u8; aead::KEY_LEN]>::try_from(bytes).ok()?;
98        Some(Self(Zeroizing::new(arr)))
99    }
100
101    /// Borrow the raw key bytes (kept on the stack inside `Zeroizing`).
102    #[must_use]
103    pub fn as_bytes(&self) -> &[u8; aead::KEY_LEN] {
104        &self.0
105    }
106}
107
108/// A slot specification for `seal` / `add_slot`: the method that will back the
109/// slot and its operator-facing label.
110pub struct SlotSpec<'a> {
111    /// The unlock method (already configured with its recipient/phrase/etc.).
112    pub method: &'a dyn UnlockMethod,
113    /// Operator-facing label, e.g. `"primary-yubikey"` / `"break-glass-2026"`.
114    pub label: String,
115}
116
117/// Current unix time in seconds (saturating; never panics).
118fn now_unix() -> u64 {
119    std::time::SystemTime::now()
120        .duration_since(std::time::UNIX_EPOCH)
121        .map_or(0, |d| d.as_secs())
122}
123
124/// Seal a fresh bundle: pick a random master KEK, encrypt `payload` under it,
125/// and wrap the KEK into one slot per `specs`. Returns the on-disk file bytes.
126///
127/// At least one slot is required (a bundle with no slots cannot be opened).
128///
129/// # Errors
130/// [`SealError`] on serialization, AEAD, or per-method wrap failure; fails closed.
131pub fn seal(payload: &CredBundle, specs: &[SlotSpec<'_>]) -> Result<Vec<u8>, SealError> {
132    if specs.is_empty() {
133        return Err(SealError::LastSlot);
134    }
135    let kek = MasterKek::generate();
136    let mut bundle_id = [0u8; 16];
137    rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut bundle_id);
138
139    let header = Header {
140        format_version: FORMAT_VERSION,
141        suite: Suite::v1(),
142        bundle_id,
143        created_unix: now_unix(),
144        epoch: 1,
145    };
146    let header_aad = header.to_aad_bytes()?;
147
148    // Encrypt the payload once under the KEK.
149    let sealed_payload = encrypt_payload(&kek, &header_aad, payload)?;
150
151    // Wrap the KEK into each slot (ids assigned 0..N).
152    let mut slots = Vec::with_capacity(specs.len());
153    for (idx, spec) in specs.iter().enumerate() {
154        let slot_id = u32::try_from(idx).map_err(|_| SealError::Format("too many slots".into()))?;
155        let (params, wrap) = spec.method.wrap_kek(&kek, &header_aad, slot_id)?;
156        slots.push(Slot {
157            slot_id,
158            method: spec.method.kind(),
159            label: spec.label.clone(),
160            created_unix: now_unix(),
161            params,
162            wrap,
163        });
164    }
165
166    format::encode(&header, &header_aad, slots, sealed_payload)
167}
168
169/// Open a bundle and return its [`CredBundle`].
170///
171/// Walks the slot table, tries each slot whose method is enabled + available,
172/// recovers the master KEK on first success, decrypts the payload, then
173/// **zeroizes the KEK**.
174///
175/// `methods` maps a [`MethodKind`] to a configured [`UnlockMethod`] able to
176/// recover that slot (e.g. a `Bip39Method` holding the operator's phrase). A
177/// `None` entry (or absent kind) means that method is not available this boot.
178///
179/// # Errors
180/// [`SealError::NoSlotOpened`] if nothing opens (fail closed); [`SealError::AuthFailed`]
181/// if the payload AEAD fails after a slot recovered a KEK.
182pub fn open_bundle(
183    parsed: &ParsedBundle,
184    methods: &MethodRegistry<'_>,
185) -> Result<CredBundle, SealError> {
186    let header_aad = parsed.header_aad();
187
188    for slot in &parsed.body.slots {
189        let Some(method) = openable_method(slot, methods, SlotLog::Open) else {
190            continue;
191        };
192        match method.recover_kek(slot, header_aad) {
193            Ok(kek) => {
194                tracing::info!(
195                    slot_id = slot.slot_id,
196                    method = %slot.method,
197                    "unlock slot opened"
198                );
199                let payload = decrypt_payload(&kek, header_aad, &parsed.body.payload)?;
200                // KEK zeroized here as `kek` drops at end of scope (Zeroizing).
201                drop(kek);
202                return Ok(payload);
203            }
204            Err(e) => {
205                tracing::warn!(
206                    slot_id = slot.slot_id,
207                    method = %slot.method,
208                    error = %e,
209                    "unlock slot failed"
210                );
211            }
212        }
213    }
214    Err(SealError::NoSlotOpened)
215}
216
217/// Replace the payload of an existing bundle (the `set-cred` flow, §6.2).
218///
219/// Opens via `methods` to recover the master KEK, swaps in `new_payload`, and
220/// re-encrypts under the **same** KEK and the **same** header AAD (so no slot
221/// rewrap is needed), returning the new file bytes.
222///
223/// NOTE on `epoch`: §6.2 calls for `epoch += 1` on a cred change, but the header
224/// (and thus its `epoch`) is the AEAD AAD for *both* the payload and every slot
225/// wrap, so bumping it here would invalidate the un-rewrapped slot wraps. The
226/// epoch is therefore held stable on `set-cred`; a monotonic-epoch bump belongs
227/// with the deferred §6.4 sidecar / full re-seal (logged as an OFI). The payload
228/// still gets a fresh AEAD nonce, so the ciphertext changes on every `set-cred`.
229///
230/// # Errors
231/// [`SealError`] on open/encrypt failure. Fails closed.
232pub fn reseal_payload(
233    parsed: &ParsedBundle,
234    methods: &MethodRegistry<'_>,
235    new_payload: &CredBundle,
236) -> Result<Vec<u8>, SealError> {
237    let header = parsed.body.header.clone();
238    let header_aad = parsed.header_aad().to_vec();
239    let kek = recover_any(parsed, methods)?;
240    let sealed_payload = encrypt_payload(&kek, &header_aad, new_payload)?;
241    drop(kek);
242    format::encode_with_deposits(
243        &header,
244        &header_aad,
245        parsed.body.slots.clone(),
246        sealed_payload,
247        parsed.body.deposits.clone(),
248    )
249}
250
251/// Replace the payload and advance the monotonic epoch.
252///
253/// Unlike [`reseal_payload`], this performs the full §6.4 re-seal: it bumps the
254/// header epoch, re-encrypts the payload under the new header AAD, and rewraps
255/// every existing slot under that same new AAD. Every slot method already present
256/// in the bundle must be configured in `methods`; otherwise Basil refuses the
257/// update rather than producing a partially-openable bundle.
258///
259/// # Errors
260/// [`SealError`] on open/encrypt/rewrap failure: fail closed.
261pub fn reseal_payload_bump_epoch(
262    parsed: &ParsedBundle,
263    methods: &MethodRegistry<'_>,
264    new_payload: &CredBundle,
265) -> Result<Vec<u8>, SealError> {
266    reseal_payload_bump_epoch_with_deposits(
267        parsed,
268        methods,
269        new_payload,
270        parsed.body.deposits.clone(),
271    )
272}
273
274/// Replace the payload, set the deposit log, and advance the monotonic epoch.
275///
276/// # Errors
277/// [`SealError`] on open/encrypt/rewrap failure; fails closed.
278pub fn reseal_payload_bump_epoch_with_deposits(
279    parsed: &ParsedBundle,
280    methods: &MethodRegistry<'_>,
281    new_payload: &CredBundle,
282    deposits: Vec<DepositRecord>,
283) -> Result<Vec<u8>, SealError> {
284    let mut header = parsed.body.header.clone();
285    header.epoch = header
286        .epoch
287        .checked_add(1)
288        .ok_or_else(|| SealError::Format("bundle epoch overflow".into()))?;
289    let header_aad = header.to_aad_bytes()?;
290    let kek = recover_any(parsed, methods)?;
291    let sealed_payload = encrypt_payload(&kek, &header_aad, new_payload)?;
292    let slots = rewrap_slots(parsed, methods, &kek, &header_aad)?;
293    drop(kek);
294    format::encode_with_deposits(&header, &header_aad, slots, sealed_payload, deposits)
295}
296
297/// Add a slot to an existing bundle (§2.7).
298///
299/// Opens via `methods` to recover the KEK, wraps it for `spec`'s method under
300/// the **existing** header AAD, appends the slot, and returns the new file
301/// bytes. The payload is untouched.
302///
303/// # Errors
304/// [`SealError`] on open or wrap failure, failing closed.
305pub fn add_slot(
306    parsed: &ParsedBundle,
307    methods: &MethodRegistry<'_>,
308    spec: &SlotSpec<'_>,
309) -> Result<Vec<u8>, SealError> {
310    let header = parsed.body.header.clone();
311    let header_aad = parsed.header_aad().to_vec();
312    let kek = recover_any(parsed, methods)?;
313
314    let next_id = parsed
315        .body
316        .slots
317        .iter()
318        .map(|s| s.slot_id)
319        .max()
320        .map_or(0, |m| m.saturating_add(1));
321    let (params, wrap) = spec.method.wrap_kek(&kek, &header_aad, next_id)?;
322    drop(kek);
323
324    let mut slots = parsed.body.slots.clone();
325    slots.push(Slot {
326        slot_id: next_id,
327        method: spec.method.kind(),
328        label: spec.label.clone(),
329        created_unix: now_unix(),
330        params,
331        wrap,
332    });
333    format::encode_with_deposits(
334        &header,
335        &header_aad,
336        slots,
337        parsed.body.payload.clone(),
338        parsed.body.deposits.clone(),
339    )
340}
341
342/// Remove a slot by id (§2.7). Refuses to remove the last remaining slot.
343///
344/// # Errors
345/// [`SealError::SlotNotFound`] / [`SealError::LastSlot`]: fail closed.
346pub fn remove_slot(parsed: &ParsedBundle, slot_id: u32) -> Result<Vec<u8>, SealError> {
347    if parsed.body.slots.len() <= 1 {
348        return Err(SealError::LastSlot);
349    }
350    if !parsed.body.slots.iter().any(|s| s.slot_id == slot_id) {
351        return Err(SealError::SlotNotFound(slot_id));
352    }
353    let slots: Vec<Slot> = parsed
354        .body
355        .slots
356        .iter()
357        .filter(|s| s.slot_id != slot_id)
358        .cloned()
359        .collect();
360    let header = parsed.body.header.clone();
361    let header_aad = parsed.header_aad().to_vec();
362    format::encode_with_deposits(
363        &header,
364        &header_aad,
365        slots,
366        parsed.body.payload.clone(),
367        parsed.body.deposits.clone(),
368    )
369}
370
371/// Verify the bundle epoch against a 0600 sidecar, then persist the current
372/// epoch as the new last-seen value.
373///
374/// Missing sidecars are initialized to the bundle's current epoch. A sidecar with
375/// a higher epoch than the bundle means an older bundle was swapped in and is
376/// refused. A sidecar with a lower epoch is advanced.
377///
378/// **Not a security boundary against a local writer.** Anyone who can replace
379/// the broker-owned bundle can typically also delete the sidecar, and a missing
380/// sidecar is (re)initialized rather than refused: the check only catches
381/// *accidental* rollback (a restored backup, a mis-synced deploy). Deliberate
382/// rollback resistance requires the deferred TPM NV binding. Callers must run
383/// this check on the parsed header **before** opening the bundle, so a stale
384/// bundle is refused without decrypting its payload or applying its deposit log.
385/// Only the plaintext header epoch is consumed here; the header is authenticated
386/// later, as the payload/slot AAD, when the bundle opens.
387///
388/// # Errors
389/// [`SealError::Format`] on stale bundles or sidecar IO/parse errors.
390pub fn verify_epoch_sidecar(
391    parsed: &ParsedBundle,
392    sidecar_path: &std::path::Path,
393) -> Result<(), SealError> {
394    let current = parsed.body.header.epoch;
395    match std::fs::read_to_string(sidecar_path) {
396        Ok(raw) => {
397            let seen = raw
398                .trim()
399                .parse::<u64>()
400                .map_err(|e| SealError::Format(format!("epoch sidecar parse: {e}")))?;
401            if current < seen {
402                return Err(SealError::Format(format!(
403                    "bundle epoch rollback: current {current}, last seen {seen}"
404                )));
405            }
406        }
407        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
408        Err(e) => return Err(SealError::Format(format!("epoch sidecar read: {e}"))),
409    }
410    write_epoch_sidecar(sidecar_path, current)
411}
412
413/// Write the bundle epoch sidecar as owner-only text.
414///
415/// # Errors
416/// [`SealError::Format`] on sidecar IO errors.
417pub fn write_epoch_sidecar(sidecar_path: &std::path::Path, epoch: u64) -> Result<(), SealError> {
418    let tmp = sidecar_path.with_extension("epoch.tmp");
419    {
420        let mut opts = std::fs::OpenOptions::new();
421        opts.create(true).write(true).truncate(true);
422        #[cfg(unix)]
423        {
424            use std::os::unix::fs::OpenOptionsExt as _;
425            opts.mode(0o600);
426        }
427        std::io::Write::write_all(
428            &mut opts
429                .open(&tmp)
430                .map_err(|e| SealError::Format(format!("epoch sidecar open: {e}")))?,
431            format!("{epoch}\n").as_bytes(),
432        )
433        .map_err(|e| SealError::Format(format!("epoch sidecar write: {e}")))?;
434    }
435    std::fs::rename(&tmp, sidecar_path)
436        .map_err(|e| SealError::Format(format!("epoch sidecar rename: {e}")))?;
437    #[cfg(unix)]
438    {
439        use std::os::unix::fs::PermissionsExt;
440        std::fs::set_permissions(sidecar_path, std::fs::Permissions::from_mode(0o600))
441            .map_err(|e| SealError::Format(format!("epoch sidecar chmod: {e}")))?;
442    }
443    Ok(())
444}
445
446/// Recover the master KEK from any openable slot (shared by add/reseal).
447fn recover_any(
448    parsed: &ParsedBundle,
449    methods: &MethodRegistry<'_>,
450) -> Result<MasterKek, SealError> {
451    let header_aad = parsed.header_aad();
452    for slot in &parsed.body.slots {
453        let Some(method) = openable_method(slot, methods, SlotLog::Quiet) else {
454            continue;
455        };
456        if let Ok(kek) = recover_slot_kek(method, slot, header_aad) {
457            return Ok(kek);
458        }
459    }
460    Err(SealError::NoSlotOpened)
461}
462
463#[derive(Clone, Copy, PartialEq, Eq)]
464enum SlotLog {
465    Open,
466    Quiet,
467}
468
469fn openable_method<'a>(
470    slot: &Slot,
471    methods: &MethodRegistry<'a>,
472    log: SlotLog,
473) -> Option<&'a dyn UnlockMethod> {
474    let method = methods.get(slot.method).or_else(|| {
475        log_missing_method(slot, log);
476        None
477    })?;
478    if method.available() {
479        Some(method)
480    } else {
481        log_unavailable_method(slot, log);
482        None
483    }
484}
485
486fn recover_slot_kek(
487    method: &dyn UnlockMethod,
488    slot: &Slot,
489    header_aad: &[u8],
490) -> Result<MasterKek, UnlockError> {
491    method.recover_kek(slot, header_aad)
492}
493
494fn log_missing_method(slot: &Slot, log: SlotLog) {
495    if log != SlotLog::Open {
496        return;
497    }
498    match slot.method {
499        // Feature-off builds carry only the reserved fail-closed TPM method, so
500        // a TPM slot is genuinely unimplemented on this build. With `unlock-tpm`
501        // on, a missing TPM method just means none was registered for this open
502        // (e.g. no device), which the generic arm reports.
503        #[cfg(not(feature = "unlock-tpm"))]
504        MethodKind::Tpm => tracing::warn!(
505            slot_id = slot.slot_id,
506            "skipping tpm slot: not implemented (fail-closed)"
507        ),
508        other => tracing::warn!(
509            slot_id = slot.slot_id,
510            method = %other,
511            "skipping slot: no configured unlock method"
512        ),
513    }
514}
515
516fn log_unavailable_method(slot: &Slot, log: SlotLog) {
517    if log == SlotLog::Open {
518        tracing::debug!(
519            slot_id = slot.slot_id,
520            method = %slot.method,
521            "slot method unavailable"
522        );
523    }
524}
525
526fn rewrap_slots(
527    parsed: &ParsedBundle,
528    methods: &MethodRegistry<'_>,
529    kek: &MasterKek,
530    header_aad: &[u8],
531) -> Result<Vec<Slot>, SealError> {
532    let mut slots = Vec::with_capacity(parsed.body.slots.len());
533    for slot in &parsed.body.slots {
534        let method = methods.get(slot.method).ok_or(SealError::NoSlotOpened)?;
535        if !method.available() {
536            return Err(SealError::NoSlotOpened);
537        }
538        let (params, wrap) = method.wrap_kek(kek, header_aad, slot.slot_id)?;
539        slots.push(Slot {
540            slot_id: slot.slot_id,
541            method: slot.method,
542            label: slot.label.clone(),
543            created_unix: slot.created_unix,
544            params,
545            wrap,
546        });
547    }
548    Ok(slots)
549}
550
551/// Encrypt the cred map under the KEK into a [`SealedPayload`].
552fn encrypt_payload(
553    kek: &MasterKek,
554    header_aad: &[u8],
555    payload: &CredBundle,
556) -> Result<SealedPayload, SealError> {
557    let plaintext =
558        Zeroizing::new(serde_json::to_vec(payload).map_err(|e| SealError::Payload(e.to_string()))?);
559    let nonce = aead::fresh_nonce();
560    let ciphertext = aead::seal(kek.as_bytes(), &nonce, header_aad, &plaintext)?;
561    Ok(SealedPayload {
562        nonce: B64Bytes(nonce.to_vec()),
563        ciphertext: B64Bytes(ciphertext),
564    })
565}
566
567/// Decrypt the [`SealedPayload`] under the KEK back into a [`CredBundle`].
568fn decrypt_payload(
569    kek: &MasterKek,
570    header_aad: &[u8],
571    payload: &SealedPayload,
572) -> Result<CredBundle, SealError> {
573    let nonce: [u8; aead::NONCE_LEN] = payload
574        .nonce
575        .0
576        .as_slice()
577        .try_into()
578        .map_err(|_| SealError::Format("bad payload nonce length".into()))?;
579    let plaintext = aead::open(kek.as_bytes(), &nonce, header_aad, &payload.ciphertext.0)?;
580    serde_json::from_slice(&plaintext).map_err(|e| SealError::Payload(e.to_string()))
581}
582
583/// A registry of configured unlock methods, keyed by [`MethodKind`].
584///
585/// The broker builds this from config at startup (which method has the phrase /
586/// recipient / key file this boot). `open_bundle` consults it per slot.
587#[derive(Default)]
588pub struct MethodRegistry<'a> {
589    age_yubikey: Option<&'a dyn UnlockMethod>,
590    bip39: Option<&'a dyn UnlockMethod>,
591    passphrase: Option<&'a dyn UnlockMethod>,
592    tpm: Option<&'a dyn UnlockMethod>,
593}
594
595impl<'a> MethodRegistry<'a> {
596    /// An empty registry (nothing available).
597    #[must_use]
598    pub fn new() -> Self {
599        Self::default()
600    }
601
602    /// Register a method under its kind (last registration wins).
603    #[must_use]
604    pub fn with(mut self, method: &'a dyn UnlockMethod) -> Self {
605        match method.kind() {
606            MethodKind::AgeYubikey => self.age_yubikey = Some(method),
607            MethodKind::Bip39 => self.bip39 = Some(method),
608            MethodKind::Passphrase => self.passphrase = Some(method),
609            MethodKind::Tpm => self.tpm = Some(method),
610        }
611        self
612    }
613
614    /// The configured method for `kind`, if any.
615    #[must_use]
616    pub fn get(&self, kind: MethodKind) -> Option<&'a dyn UnlockMethod> {
617        match kind {
618            MethodKind::AgeYubikey => self.age_yubikey,
619            MethodKind::Bip39 => self.bip39,
620            MethodKind::Passphrase => self.passphrase,
621            MethodKind::Tpm => self.tpm,
622        }
623    }
624}
625
626// The bundle integration tests exercise both the bip39 and passphrase slots, so they
627// build only when both method features are on (e.g. `--all-features`). The
628// per-method unit tests in `unlock::{bip39,passphrase,...}` cover each slot under its
629// own feature; the container/aead/cred tests are feature-independent.
630#[cfg(all(test, feature = "unlock-bip39"))]
631mod tests;