1pub 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#[derive(Debug, thiserror::Error)]
45pub enum SealError {
46 #[error("bundle format: {0}")]
48 Format(String),
49
50 #[error("authentication failed (tampered or wrong key)")]
52 AuthFailed,
53
54 #[error("crypto: {0}")]
56 Crypto(String),
57
58 #[error("no unlock slot could open the bundle")]
60 NoSlotOpened,
61
62 #[error("refusing to remove the last slot")]
64 LastSlot,
65
66 #[error("slot {0} not found")]
68 SlotNotFound(u32),
69
70 #[error(transparent)]
72 Unlock(#[from] UnlockError),
73
74 #[error("payload encode/decode: {0}")]
76 Payload(String),
77}
78
79pub struct MasterKek(Zeroizing<[u8; aead::KEY_LEN]>);
82
83impl MasterKek {
84 #[must_use]
86 pub fn generate() -> Self {
87 Self(aead::fresh_key())
88 }
89
90 #[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 #[must_use]
99 pub fn as_bytes(&self) -> &[u8; aead::KEY_LEN] {
100 &self.0
101 }
102}
103
104pub struct SlotSpec<'a> {
107 pub method: &'a dyn UnlockMethod,
109 pub label: String,
111}
112
113fn 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
120pub 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 let sealed_payload = encrypt_payload(&kek, &header_aad, payload)?;
146
147 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
165pub 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 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
213pub 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
247pub 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
270pub 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
293pub 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
338pub 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
367pub 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
401pub 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
434fn 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 #[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
539fn 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
555fn 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#[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 #[must_use]
586 pub fn new() -> Self {
587 Self::default()
588 }
589
590 #[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 #[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#[cfg(all(test, feature = "unlock-bip39"))]
619mod tests;