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;
#[derive(Debug, thiserror::Error)]
pub enum SealError {
#[error("bundle format: {0}")]
Format(String),
#[error("authentication failed (tampered or wrong key)")]
AuthFailed,
#[error("crypto: {0}")]
Crypto(String),
#[error("no unlock slot could open the bundle")]
NoSlotOpened,
#[error("refusing to remove the last slot")]
LastSlot,
#[error("slot {0} not found")]
SlotNotFound(u32),
#[error(transparent)]
Unlock(#[from] UnlockError),
#[error("payload encode/decode: {0}")]
Payload(String),
}
pub struct MasterKek(Zeroizing<[u8; aead::KEY_LEN]>);
impl MasterKek {
#[must_use]
pub fn generate() -> Self {
Self(aead::fresh_key())
}
#[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)))
}
#[must_use]
pub fn as_bytes(&self) -> &[u8; aead::KEY_LEN] {
&self.0
}
}
pub struct SlotSpec<'a> {
pub method: &'a dyn UnlockMethod,
pub label: String,
}
fn now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
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()?;
let sealed_payload = encrypt_payload(&kek, &header_aad, payload)?;
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)
}
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)?;
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)
}
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(),
)
}
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(),
)
}
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)
}
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(),
)
}
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(),
)
}
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)
}
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(())
}
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 {
#[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)
}
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),
})
}
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()))
}
#[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> {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[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
}
#[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,
}
}
}
#[cfg(all(test, feature = "unlock-bip39"))]
mod tests;