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(
383 parsed: &ParsedBundle,
384 sidecar_path: &std::path::Path,
385) -> Result<(), SealError> {
386 let current = parsed.body.header.epoch;
387 match std::fs::read_to_string(sidecar_path) {
388 Ok(raw) => {
389 let seen = raw
390 .trim()
391 .parse::<u64>()
392 .map_err(|e| SealError::Format(format!("epoch sidecar parse: {e}")))?;
393 if current < seen {
394 return Err(SealError::Format(format!(
395 "bundle epoch rollback: current {current}, last seen {seen}"
396 )));
397 }
398 }
399 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
400 Err(e) => return Err(SealError::Format(format!("epoch sidecar read: {e}"))),
401 }
402 write_epoch_sidecar(sidecar_path, current)
403}
404
405pub fn write_epoch_sidecar(sidecar_path: &std::path::Path, epoch: u64) -> Result<(), SealError> {
410 let tmp = sidecar_path.with_extension("epoch.tmp");
411 {
412 let mut opts = std::fs::OpenOptions::new();
413 opts.create(true).write(true).truncate(true);
414 #[cfg(unix)]
415 {
416 use std::os::unix::fs::OpenOptionsExt as _;
417 opts.mode(0o600);
418 }
419 std::io::Write::write_all(
420 &mut opts
421 .open(&tmp)
422 .map_err(|e| SealError::Format(format!("epoch sidecar open: {e}")))?,
423 format!("{epoch}\n").as_bytes(),
424 )
425 .map_err(|e| SealError::Format(format!("epoch sidecar write: {e}")))?;
426 }
427 std::fs::rename(&tmp, sidecar_path)
428 .map_err(|e| SealError::Format(format!("epoch sidecar rename: {e}")))?;
429 #[cfg(unix)]
430 {
431 use std::os::unix::fs::PermissionsExt;
432 std::fs::set_permissions(sidecar_path, std::fs::Permissions::from_mode(0o600))
433 .map_err(|e| SealError::Format(format!("epoch sidecar chmod: {e}")))?;
434 }
435 Ok(())
436}
437
438fn recover_any(
440 parsed: &ParsedBundle,
441 methods: &MethodRegistry<'_>,
442) -> Result<MasterKek, SealError> {
443 let header_aad = parsed.header_aad();
444 for slot in &parsed.body.slots {
445 let Some(method) = openable_method(slot, methods, SlotLog::Quiet) else {
446 continue;
447 };
448 if let Ok(kek) = recover_slot_kek(method, slot, header_aad) {
449 return Ok(kek);
450 }
451 }
452 Err(SealError::NoSlotOpened)
453}
454
455#[derive(Clone, Copy, PartialEq, Eq)]
456enum SlotLog {
457 Open,
458 Quiet,
459}
460
461fn openable_method<'a>(
462 slot: &Slot,
463 methods: &MethodRegistry<'a>,
464 log: SlotLog,
465) -> Option<&'a dyn UnlockMethod> {
466 let method = methods.get(slot.method).or_else(|| {
467 log_missing_method(slot, log);
468 None
469 })?;
470 if method.available() {
471 Some(method)
472 } else {
473 log_unavailable_method(slot, log);
474 None
475 }
476}
477
478fn recover_slot_kek(
479 method: &dyn UnlockMethod,
480 slot: &Slot,
481 header_aad: &[u8],
482) -> Result<MasterKek, UnlockError> {
483 method.recover_kek(slot, header_aad)
484}
485
486fn log_missing_method(slot: &Slot, log: SlotLog) {
487 if log != SlotLog::Open {
488 return;
489 }
490 match slot.method {
491 #[cfg(not(feature = "unlock-tpm"))]
496 MethodKind::Tpm => tracing::warn!(
497 slot_id = slot.slot_id,
498 "skipping tpm slot: not implemented (fail-closed)"
499 ),
500 other => tracing::warn!(
501 slot_id = slot.slot_id,
502 method = %other,
503 "skipping slot: no configured unlock method"
504 ),
505 }
506}
507
508fn log_unavailable_method(slot: &Slot, log: SlotLog) {
509 if log == SlotLog::Open {
510 tracing::debug!(
511 slot_id = slot.slot_id,
512 method = %slot.method,
513 "slot method unavailable"
514 );
515 }
516}
517
518fn rewrap_slots(
519 parsed: &ParsedBundle,
520 methods: &MethodRegistry<'_>,
521 kek: &MasterKek,
522 header_aad: &[u8],
523) -> Result<Vec<Slot>, SealError> {
524 let mut slots = Vec::with_capacity(parsed.body.slots.len());
525 for slot in &parsed.body.slots {
526 let method = methods.get(slot.method).ok_or(SealError::NoSlotOpened)?;
527 if !method.available() {
528 return Err(SealError::NoSlotOpened);
529 }
530 let (params, wrap) = method.wrap_kek(kek, header_aad, slot.slot_id)?;
531 slots.push(Slot {
532 slot_id: slot.slot_id,
533 method: slot.method,
534 label: slot.label.clone(),
535 created_unix: slot.created_unix,
536 params,
537 wrap,
538 });
539 }
540 Ok(slots)
541}
542
543fn encrypt_payload(
545 kek: &MasterKek,
546 header_aad: &[u8],
547 payload: &CredBundle,
548) -> Result<SealedPayload, SealError> {
549 let plaintext =
550 Zeroizing::new(serde_json::to_vec(payload).map_err(|e| SealError::Payload(e.to_string()))?);
551 let nonce = aead::fresh_nonce();
552 let ciphertext = aead::seal(kek.as_bytes(), &nonce, header_aad, &plaintext)?;
553 Ok(SealedPayload {
554 nonce: B64Bytes(nonce.to_vec()),
555 ciphertext: B64Bytes(ciphertext),
556 })
557}
558
559fn decrypt_payload(
561 kek: &MasterKek,
562 header_aad: &[u8],
563 payload: &SealedPayload,
564) -> Result<CredBundle, SealError> {
565 let nonce: [u8; aead::NONCE_LEN] = payload
566 .nonce
567 .0
568 .as_slice()
569 .try_into()
570 .map_err(|_| SealError::Format("bad payload nonce length".into()))?;
571 let plaintext = aead::open(kek.as_bytes(), &nonce, header_aad, &payload.ciphertext.0)?;
572 serde_json::from_slice(&plaintext).map_err(|e| SealError::Payload(e.to_string()))
573}
574
575#[derive(Default)]
580pub struct MethodRegistry<'a> {
581 age_yubikey: Option<&'a dyn UnlockMethod>,
582 bip39: Option<&'a dyn UnlockMethod>,
583 passphrase: Option<&'a dyn UnlockMethod>,
584 tpm: Option<&'a dyn UnlockMethod>,
585}
586
587impl<'a> MethodRegistry<'a> {
588 #[must_use]
590 pub fn new() -> Self {
591 Self::default()
592 }
593
594 #[must_use]
596 pub fn with(mut self, method: &'a dyn UnlockMethod) -> Self {
597 match method.kind() {
598 MethodKind::AgeYubikey => self.age_yubikey = Some(method),
599 MethodKind::Bip39 => self.bip39 = Some(method),
600 MethodKind::Passphrase => self.passphrase = Some(method),
601 MethodKind::Tpm => self.tpm = Some(method),
602 }
603 self
604 }
605
606 #[must_use]
608 pub fn get(&self, kind: MethodKind) -> Option<&'a dyn UnlockMethod> {
609 match kind {
610 MethodKind::AgeYubikey => self.age_yubikey,
611 MethodKind::Bip39 => self.bip39,
612 MethodKind::Passphrase => self.passphrase,
613 MethodKind::Tpm => self.tpm,
614 }
615 }
616}
617
618#[cfg(all(test, feature = "unlock-bip39"))]
623mod tests;