basil-core 0.7.1

Basil daemon core, broker services, transport, and offline admin command implementations.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
//
// SPDX-License-Identifier: Apache-2.0

//! Sealed-bundle seal / open / slot management (vault-vh1).
//!
//! Implements `designs/unlock-and-bundle.html`: a `0600` multi-slot sealed bundle
//! holding the broker's own bootstrap credentials. One 32-byte master KEK
//! AES-256-GCM-encrypts the payload (the [`CredBundle`] cred map, §4); *N*
//! independent [`Slot`]s each wrap that *same* KEK via a different
//! [`UnlockMethod`]. Adding/removing a method rewraps one slot and leaves the
//! payload untouched. The header is bound as AAD over the payload AEAD and every
//! slot wrap (anti-downgrade/splice, §2.2).
//!
//! Everything here fails **closed** and never panics on a runtime path (§1.3):
//! every fallible step returns a `Result`, the master KEK and the decrypted
//! [`CredBundle`] are `Zeroizing`, and the KEK is wiped as early as possible.

pub mod aead;
pub mod cred;
pub mod deposit;
pub mod format;
pub mod unlock;

use zeroize::Zeroizing;

pub use cred::{BackendCred, CRED_SCHEMA_VERSION, CredBundle, DepositContributor};
pub use deposit::{
    DepositAction, DepositReview, DepositStatus, apply_authorized_deposits,
    contributor_public_token, create_signed_record, promote_deposits, public_key_from_token,
    public_key_token, review_deposits,
};
pub use format::{
    Argon2Params, B64Bytes, BundleBody, DepositRecord, DepositSealedCred, FORMAT_VERSION, Header,
    KekWrap, MAGIC, MethodKind, MethodParams, ParsedBundle, SealedPayload, Slot, Suite,
};
pub use unlock::{UnlockError, UnlockMethod};

#[cfg(feature = "unlock-age-yubikey")]
pub use unlock::age_yubikey::AgeYubikeyMethod;
#[cfg(feature = "unlock-bip39")]
pub use unlock::bip39::Bip39Method;
pub use unlock::passphrase::PassphraseMethod;
pub use unlock::tpm::TpmMethod;

/// Errors from sealing/opening a bundle (§5). Distinct from [`UnlockError`],
/// which is per-method; this is the container/orchestration layer.
#[derive(Debug, thiserror::Error)]
pub enum SealError {
    /// Container framing / JSON / version problem (bad magic, unknown version).
    #[error("bundle format: {0}")]
    Format(String),

    /// AEAD authentication failed (tampered header/payload, wrong KEK).
    #[error("authentication failed (tampered or wrong key)")]
    AuthFailed,

    /// A crypto/serialization failure.
    #[error("crypto: {0}")]
    Crypto(String),

    /// No enabled+available slot could open the bundle. Fails closed.
    #[error("no unlock slot could open the bundle")]
    NoSlotOpened,

    /// Would remove the last slot (bricks the bundle).
    #[error("refusing to remove the last slot")]
    LastSlot,

    /// The referenced slot id is not present.
    #[error("slot {0} not found")]
    SlotNotFound(u32),

    /// A method-level unlock error surfaced during seal/open.
    #[error(transparent)]
    Unlock(#[from] UnlockError),

    /// A serde error during payload (de)serialization.
    #[error("payload encode/decode: {0}")]
    Payload(String),
}

/// 32-byte master KEK that zeroizes on drop (the newtype the §3 trait sketch
/// calls `KeyMaterial`; renamed to avoid colliding with `proto::KeyMaterial`).
pub struct MasterKek(Zeroizing<[u8; aead::KEY_LEN]>);

impl MasterKek {
    /// Draw a fresh master KEK from the OS CSPRNG.
    #[must_use]
    pub fn generate() -> Self {
        Self(aead::fresh_key())
    }

    /// Build from exactly 32 bytes, or `None` if the length is wrong.
    #[must_use]
    pub fn from_slice(bytes: &[u8]) -> Option<Self> {
        let arr = <[u8; aead::KEY_LEN]>::try_from(bytes).ok()?;
        Some(Self(Zeroizing::new(arr)))
    }

    /// Borrow the raw key bytes (kept on the stack inside `Zeroizing`).
    #[must_use]
    pub fn as_bytes(&self) -> &[u8; aead::KEY_LEN] {
        &self.0
    }
}

/// A slot specification for `seal` / `add_slot`: the method that will back the
/// slot and its operator-facing label.
pub struct SlotSpec<'a> {
    /// The unlock method (already configured with its recipient/phrase/etc.).
    pub method: &'a dyn UnlockMethod,
    /// Operator-facing label, e.g. `"primary-yubikey"` / `"break-glass-2026"`.
    pub label: String,
}

/// Current unix time in seconds (saturating; never panics).
fn now_unix() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_or(0, |d| d.as_secs())
}

/// Seal a fresh bundle: pick a random master KEK, encrypt `payload` under it,
/// and wrap the KEK into one slot per `specs`. Returns the on-disk file bytes.
///
/// At least one slot is required (a bundle with no slots cannot be opened).
///
/// # Errors
/// [`SealError`] on serialization, AEAD, or per-method wrap failure; fails closed.
pub fn seal(payload: &CredBundle, specs: &[SlotSpec<'_>]) -> Result<Vec<u8>, SealError> {
    if specs.is_empty() {
        return Err(SealError::LastSlot);
    }
    let kek = MasterKek::generate();
    let mut bundle_id = [0u8; 16];
    rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut bundle_id);

    let header = Header {
        format_version: FORMAT_VERSION,
        suite: Suite::v1(),
        bundle_id,
        created_unix: now_unix(),
        epoch: 1,
    };
    let header_aad = header.to_aad_bytes()?;

    // Encrypt the payload once under the KEK.
    let sealed_payload = encrypt_payload(&kek, &header_aad, payload)?;

    // Wrap the KEK into each slot (ids assigned 0..N).
    let mut slots = Vec::with_capacity(specs.len());
    for (idx, spec) in specs.iter().enumerate() {
        let slot_id = u32::try_from(idx).map_err(|_| SealError::Format("too many slots".into()))?;
        let (params, wrap) = spec.method.wrap_kek(&kek, &header_aad, slot_id)?;
        slots.push(Slot {
            slot_id,
            method: spec.method.kind(),
            label: spec.label.clone(),
            created_unix: now_unix(),
            params,
            wrap,
        });
    }

    format::encode(&header, &header_aad, slots, sealed_payload)
}

/// Open a bundle and return its [`CredBundle`].
///
/// Walks the slot table, tries each slot whose method is enabled + available,
/// recovers the master KEK on first success, decrypts the payload, then
/// **zeroizes the KEK**.
///
/// `methods` maps a [`MethodKind`] to a configured [`UnlockMethod`] able to
/// recover that slot (e.g. a `Bip39Method` holding the operator's phrase). A
/// `None` entry (or absent kind) means that method is not available this boot.
///
/// # Errors
/// [`SealError::NoSlotOpened`] if nothing opens (fail closed); [`SealError::AuthFailed`]
/// if the payload AEAD fails after a slot recovered a KEK.
pub fn open_bundle(
    parsed: &ParsedBundle,
    methods: &MethodRegistry<'_>,
) -> Result<CredBundle, SealError> {
    let header_aad = parsed.header_aad();

    for slot in &parsed.body.slots {
        let Some(method) = openable_method(slot, methods, SlotLog::Open) else {
            continue;
        };
        match method.recover_kek(slot, header_aad) {
            Ok(kek) => {
                tracing::info!(
                    slot_id = slot.slot_id,
                    method = %slot.method,
                    "unlock slot opened"
                );
                let payload = decrypt_payload(&kek, header_aad, &parsed.body.payload)?;
                // KEK zeroized here as `kek` drops at end of scope (Zeroizing).
                drop(kek);
                return Ok(payload);
            }
            Err(e) => {
                tracing::warn!(
                    slot_id = slot.slot_id,
                    method = %slot.method,
                    error = %e,
                    "unlock slot failed"
                );
            }
        }
    }
    Err(SealError::NoSlotOpened)
}

/// Replace the payload of an existing bundle (the `set-cred` flow, §6.2).
///
/// Opens via `methods` to recover the master KEK, swaps in `new_payload`, and
/// re-encrypts under the **same** KEK and the **same** header AAD (so no slot
/// rewrap is needed), returning the new file bytes.
///
/// NOTE on `epoch`: §6.2 calls for `epoch += 1` on a cred change, but the header
/// (and thus its `epoch`) is the AEAD AAD for *both* the payload and every slot
/// wrap, so bumping it here would invalidate the un-rewrapped slot wraps. The
/// epoch is therefore held stable on `set-cred`; a monotonic-epoch bump belongs
/// with the deferred §6.4 sidecar / full re-seal (logged as an OFI). The payload
/// still gets a fresh AEAD nonce, so the ciphertext changes on every `set-cred`.
///
/// # Errors
/// [`SealError`] on open/encrypt failure. Fails closed.
pub fn reseal_payload(
    parsed: &ParsedBundle,
    methods: &MethodRegistry<'_>,
    new_payload: &CredBundle,
) -> Result<Vec<u8>, SealError> {
    let header = parsed.body.header.clone();
    let header_aad = parsed.header_aad().to_vec();
    let kek = recover_any(parsed, methods)?;
    let sealed_payload = encrypt_payload(&kek, &header_aad, new_payload)?;
    drop(kek);
    format::encode_with_deposits(
        &header,
        &header_aad,
        parsed.body.slots.clone(),
        sealed_payload,
        parsed.body.deposits.clone(),
    )
}

/// Replace the payload and advance the monotonic epoch.
///
/// Unlike [`reseal_payload`], this performs the full §6.4 re-seal: it bumps the
/// header epoch, re-encrypts the payload under the new header AAD, and rewraps
/// every existing slot under that same new AAD. Every slot method already present
/// in the bundle must be configured in `methods`; otherwise Basil refuses the
/// update rather than producing a partially-openable bundle.
///
/// # Errors
/// [`SealError`] on open/encrypt/rewrap failure: fail closed.
pub fn reseal_payload_bump_epoch(
    parsed: &ParsedBundle,
    methods: &MethodRegistry<'_>,
    new_payload: &CredBundle,
) -> Result<Vec<u8>, SealError> {
    reseal_payload_bump_epoch_with_deposits(
        parsed,
        methods,
        new_payload,
        parsed.body.deposits.clone(),
    )
}

/// Replace the payload, set the deposit log, and advance the monotonic epoch.
///
/// # Errors
/// [`SealError`] on open/encrypt/rewrap failure; fails closed.
pub fn reseal_payload_bump_epoch_with_deposits(
    parsed: &ParsedBundle,
    methods: &MethodRegistry<'_>,
    new_payload: &CredBundle,
    deposits: Vec<DepositRecord>,
) -> Result<Vec<u8>, SealError> {
    let mut header = parsed.body.header.clone();
    header.epoch = header
        .epoch
        .checked_add(1)
        .ok_or_else(|| SealError::Format("bundle epoch overflow".into()))?;
    let header_aad = header.to_aad_bytes()?;
    let kek = recover_any(parsed, methods)?;
    let sealed_payload = encrypt_payload(&kek, &header_aad, new_payload)?;
    let slots = rewrap_slots(parsed, methods, &kek, &header_aad)?;
    drop(kek);
    format::encode_with_deposits(&header, &header_aad, slots, sealed_payload, deposits)
}

/// Add a slot to an existing bundle (§2.7).
///
/// Opens via `methods` to recover the KEK, wraps it for `spec`'s method under
/// the **existing** header AAD, appends the slot, and returns the new file
/// bytes. The payload is untouched.
///
/// # Errors
/// [`SealError`] on open or wrap failure, failing closed.
pub fn add_slot(
    parsed: &ParsedBundle,
    methods: &MethodRegistry<'_>,
    spec: &SlotSpec<'_>,
) -> Result<Vec<u8>, SealError> {
    let header = parsed.body.header.clone();
    let header_aad = parsed.header_aad().to_vec();
    let kek = recover_any(parsed, methods)?;

    let next_id = parsed
        .body
        .slots
        .iter()
        .map(|s| s.slot_id)
        .max()
        .map_or(0, |m| m.saturating_add(1));
    let (params, wrap) = spec.method.wrap_kek(&kek, &header_aad, next_id)?;
    drop(kek);

    let mut slots = parsed.body.slots.clone();
    slots.push(Slot {
        slot_id: next_id,
        method: spec.method.kind(),
        label: spec.label.clone(),
        created_unix: now_unix(),
        params,
        wrap,
    });
    format::encode_with_deposits(
        &header,
        &header_aad,
        slots,
        parsed.body.payload.clone(),
        parsed.body.deposits.clone(),
    )
}

/// Remove a slot by id (§2.7). Refuses to remove the last remaining slot.
///
/// # Errors
/// [`SealError::SlotNotFound`] / [`SealError::LastSlot`]: fail closed.
pub fn remove_slot(parsed: &ParsedBundle, slot_id: u32) -> Result<Vec<u8>, SealError> {
    if parsed.body.slots.len() <= 1 {
        return Err(SealError::LastSlot);
    }
    if !parsed.body.slots.iter().any(|s| s.slot_id == slot_id) {
        return Err(SealError::SlotNotFound(slot_id));
    }
    let slots: Vec<Slot> = parsed
        .body
        .slots
        .iter()
        .filter(|s| s.slot_id != slot_id)
        .cloned()
        .collect();
    let header = parsed.body.header.clone();
    let header_aad = parsed.header_aad().to_vec();
    format::encode_with_deposits(
        &header,
        &header_aad,
        slots,
        parsed.body.payload.clone(),
        parsed.body.deposits.clone(),
    )
}

/// Verify the bundle epoch against a 0600 sidecar, then persist the current
/// epoch as the new last-seen value.
///
/// Missing sidecars are initialized to the bundle's current epoch. A sidecar with
/// a higher epoch than the bundle means an older bundle was swapped in and is
/// refused. A sidecar with a lower epoch is advanced.
///
/// **Not a security boundary against a local writer.** Anyone who can replace
/// the broker-owned bundle can typically also delete the sidecar, and a missing
/// sidecar is (re)initialized rather than refused: the check only catches
/// *accidental* rollback (a restored backup, a partial deploy). Deliberate
/// rollback resistance requires the deferred TPM NV binding. Callers must run
/// this check on the parsed header **before** opening the bundle, so a stale
/// bundle is refused without decrypting its payload or applying its deposit log.
/// Only the plaintext header epoch is consumed here; the header is authenticated
/// later, as the payload/slot AAD, when the bundle opens.
///
/// # Errors
/// [`SealError::Format`] on stale bundles or sidecar IO/parse errors.
pub fn verify_epoch_sidecar(
    parsed: &ParsedBundle,
    sidecar_path: &std::path::Path,
) -> Result<(), SealError> {
    let current = parsed.body.header.epoch;
    match std::fs::read_to_string(sidecar_path) {
        Ok(raw) => {
            let seen = raw
                .trim()
                .parse::<u64>()
                .map_err(|e| SealError::Format(format!("epoch sidecar parse: {e}")))?;
            if current < seen {
                return Err(SealError::Format(format!(
                    "bundle epoch rollback: current {current}, last seen {seen}"
                )));
            }
        }
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
        Err(e) => return Err(SealError::Format(format!("epoch sidecar read: {e}"))),
    }
    write_epoch_sidecar(sidecar_path, current)
}

/// Write the bundle epoch sidecar as owner-only text.
///
/// # Errors
/// [`SealError::Format`] on sidecar IO errors.
pub fn write_epoch_sidecar(sidecar_path: &std::path::Path, epoch: u64) -> Result<(), SealError> {
    let tmp = sidecar_path.with_extension("epoch.tmp");
    {
        let mut opts = std::fs::OpenOptions::new();
        opts.create(true).write(true).truncate(true);
        #[cfg(unix)]
        {
            use std::os::unix::fs::OpenOptionsExt as _;
            opts.mode(0o600);
        }
        std::io::Write::write_all(
            &mut opts
                .open(&tmp)
                .map_err(|e| SealError::Format(format!("epoch sidecar open: {e}")))?,
            format!("{epoch}\n").as_bytes(),
        )
        .map_err(|e| SealError::Format(format!("epoch sidecar write: {e}")))?;
    }
    std::fs::rename(&tmp, sidecar_path)
        .map_err(|e| SealError::Format(format!("epoch sidecar rename: {e}")))?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(sidecar_path, std::fs::Permissions::from_mode(0o600))
            .map_err(|e| SealError::Format(format!("epoch sidecar chmod: {e}")))?;
    }
    Ok(())
}

/// Recover the master KEK from any openable slot (shared by add/reseal).
fn recover_any(
    parsed: &ParsedBundle,
    methods: &MethodRegistry<'_>,
) -> Result<MasterKek, SealError> {
    let header_aad = parsed.header_aad();
    for slot in &parsed.body.slots {
        let Some(method) = openable_method(slot, methods, SlotLog::Quiet) else {
            continue;
        };
        if let Ok(kek) = recover_slot_kek(method, slot, header_aad) {
            return Ok(kek);
        }
    }
    Err(SealError::NoSlotOpened)
}

#[derive(Clone, Copy, PartialEq, Eq)]
enum SlotLog {
    Open,
    Quiet,
}

fn openable_method<'a>(
    slot: &Slot,
    methods: &MethodRegistry<'a>,
    log: SlotLog,
) -> Option<&'a dyn UnlockMethod> {
    let method = methods.get(slot.method).or_else(|| {
        log_missing_method(slot, log);
        None
    })?;
    if method.available() {
        Some(method)
    } else {
        log_unavailable_method(slot, log);
        None
    }
}

fn recover_slot_kek(
    method: &dyn UnlockMethod,
    slot: &Slot,
    header_aad: &[u8],
) -> Result<MasterKek, UnlockError> {
    method.recover_kek(slot, header_aad)
}

fn log_missing_method(slot: &Slot, log: SlotLog) {
    if log != SlotLog::Open {
        return;
    }
    match slot.method {
        // Feature-off builds carry only the reserved fail-closed TPM method, so
        // a TPM slot is genuinely unimplemented on this build. With `unlock-tpm`
        // on, a missing TPM method just means none was registered for this open
        // (e.g. no device), which the generic arm reports.
        #[cfg(not(feature = "unlock-tpm"))]
        MethodKind::Tpm => tracing::warn!(
            slot_id = slot.slot_id,
            "skipping tpm slot: not implemented (fail-closed)"
        ),
        other => tracing::warn!(
            slot_id = slot.slot_id,
            method = %other,
            "skipping slot: no configured unlock method"
        ),
    }
}

fn log_unavailable_method(slot: &Slot, log: SlotLog) {
    if log == SlotLog::Open {
        tracing::debug!(
            slot_id = slot.slot_id,
            method = %slot.method,
            "slot method unavailable"
        );
    }
}

fn rewrap_slots(
    parsed: &ParsedBundle,
    methods: &MethodRegistry<'_>,
    kek: &MasterKek,
    header_aad: &[u8],
) -> Result<Vec<Slot>, SealError> {
    let mut slots = Vec::with_capacity(parsed.body.slots.len());
    for slot in &parsed.body.slots {
        let method = methods.get(slot.method).ok_or(SealError::NoSlotOpened)?;
        let (params, wrap) = method.wrap_kek(kek, header_aad, slot.slot_id)?;
        slots.push(Slot {
            slot_id: slot.slot_id,
            method: slot.method,
            label: slot.label.clone(),
            created_unix: slot.created_unix,
            params,
            wrap,
        });
    }
    Ok(slots)
}

/// Encrypt the cred map under the KEK into a [`SealedPayload`].
fn encrypt_payload(
    kek: &MasterKek,
    header_aad: &[u8],
    payload: &CredBundle,
) -> Result<SealedPayload, SealError> {
    let plaintext =
        Zeroizing::new(serde_json::to_vec(payload).map_err(|e| SealError::Payload(e.to_string()))?);
    let nonce = aead::fresh_nonce();
    let ciphertext = aead::seal(kek.as_bytes(), &nonce, header_aad, &plaintext)?;
    Ok(SealedPayload {
        nonce: B64Bytes(nonce.to_vec()),
        ciphertext: B64Bytes(ciphertext),
    })
}

/// Decrypt the [`SealedPayload`] under the KEK back into a [`CredBundle`].
fn decrypt_payload(
    kek: &MasterKek,
    header_aad: &[u8],
    payload: &SealedPayload,
) -> Result<CredBundle, SealError> {
    let nonce: [u8; aead::NONCE_LEN] = payload
        .nonce
        .0
        .as_slice()
        .try_into()
        .map_err(|_| SealError::Format("bad payload nonce length".into()))?;
    let plaintext = aead::open(kek.as_bytes(), &nonce, header_aad, &payload.ciphertext.0)?;
    serde_json::from_slice(&plaintext).map_err(|e| SealError::Payload(e.to_string()))
}

/// A registry of configured unlock methods, keyed by [`MethodKind`].
///
/// The broker builds this from config at startup (which method has the phrase /
/// recipient / key file this boot). `open_bundle` consults it per slot.
#[derive(Default)]
pub struct MethodRegistry<'a> {
    age_yubikey: Option<&'a dyn UnlockMethod>,
    bip39: Option<&'a dyn UnlockMethod>,
    passphrase: Option<&'a dyn UnlockMethod>,
    tpm: Option<&'a dyn UnlockMethod>,
}

impl<'a> MethodRegistry<'a> {
    /// An empty registry (nothing available).
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Register a method under its kind (last registration wins).
    #[must_use]
    pub fn with(mut self, method: &'a dyn UnlockMethod) -> Self {
        match method.kind() {
            MethodKind::AgeYubikey => self.age_yubikey = Some(method),
            MethodKind::Bip39 => self.bip39 = Some(method),
            MethodKind::Passphrase => self.passphrase = Some(method),
            MethodKind::Tpm => self.tpm = Some(method),
        }
        self
    }

    /// The configured method for `kind`, if any.
    #[must_use]
    pub fn get(&self, kind: MethodKind) -> Option<&'a dyn UnlockMethod> {
        match kind {
            MethodKind::AgeYubikey => self.age_yubikey,
            MethodKind::Bip39 => self.bip39,
            MethodKind::Passphrase => self.passphrase,
            MethodKind::Tpm => self.tpm,
        }
    }
}

// The bundle integration tests exercise both the bip39 and passphrase slots, so they
// build only when both method features are on (e.g. `--all-features`). The
// per-method unit tests in `unlock::{bip39,passphrase,...}` cover each slot under its
// own feature; the container/aead/cred tests are feature-independent.
#[cfg(all(test, feature = "unlock-bip39"))]
mod tests;