1pub 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#[derive(Debug, thiserror::Error)]
49pub enum SealError {
50 #[error("bundle format: {0}")]
52 Format(String),
53
54 #[error("authentication failed (tampered or wrong key)")]
56 AuthFailed,
57
58 #[error("crypto: {0}")]
60 Crypto(String),
61
62 #[error("no unlock slot could open the bundle")]
64 NoSlotOpened,
65
66 #[error("refusing to remove the last slot")]
68 LastSlot,
69
70 #[error("slot {0} not found")]
72 SlotNotFound(u32),
73
74 #[error(transparent)]
76 Unlock(#[from] UnlockError),
77
78 #[error("payload encode/decode: {0}")]
80 Payload(String),
81}
82
83pub struct MasterKek(Zeroizing<[u8; aead::KEY_LEN]>);
86
87impl MasterKek {
88 #[must_use]
90 pub fn generate() -> Self {
91 Self(aead::fresh_key())
92 }
93
94 #[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 #[must_use]
103 pub fn as_bytes(&self) -> &[u8; aead::KEY_LEN] {
104 &self.0
105 }
106}
107
108pub struct SlotSpec<'a> {
111 pub method: &'a dyn UnlockMethod,
113 pub label: String,
115}
116
117fn 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
124pub 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 let sealed_payload = encrypt_payload(&kek, &header_aad, payload)?;
150
151 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
169pub 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 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
217pub 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
251pub 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
274pub 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
297pub 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
342pub 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
371pub 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
413pub 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
446fn 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 #[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
551fn 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
567fn 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#[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 #[must_use]
598 pub fn new() -> Self {
599 Self::default()
600 }
601
602 #[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 #[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#[cfg(all(test, feature = "unlock-bip39"))]
631mod tests;