Skip to main content

basil_core/core/seal/
mod.rs

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