use bip39::Mnemonic;
use rand::RngCore;
use zeroize::Zeroizing;
use super::super::MasterKek;
use super::super::aead::{self, NONCE_LEN};
use super::super::format::{Argon2Params, B64Bytes, KekWrap, MethodKind, MethodParams, Slot};
use super::kdf::{derive_slot_key, wrap_aad};
use super::{UnlockError, UnlockMethod};
const BIP39_WORD_COUNT: usize = 24;
const SALT_LEN: usize = 16;
pub struct Bip39Method {
phrase: Zeroizing<String>,
argon2: Argon2Params,
}
impl Bip39Method {
#[must_use]
pub const fn new(phrase: Zeroizing<String>) -> Self {
Self {
phrase,
argon2: Argon2Params::PRODUCTION,
}
}
#[must_use]
pub const fn with_params(phrase: Zeroizing<String>, argon2: Argon2Params) -> Self {
Self { phrase, argon2 }
}
pub fn generate_phrase() -> Result<Zeroizing<String>, UnlockError> {
let mut entropy = Zeroizing::new([0u8; 32]);
rand::rngs::OsRng.fill_bytes(entropy.as_mut());
let mnemonic = Mnemonic::from_entropy(entropy.as_ref())
.map_err(|e| UnlockError::Crypto(format!("bip39 generate: {e}")))?;
Ok(Zeroizing::new(mnemonic.to_string()))
}
fn entropy(&self) -> Result<Zeroizing<Vec<u8>>, UnlockError> {
let mnemonic =
Mnemonic::parse(self.phrase.as_str()).map_err(|_| UnlockError::AuthFailed)?;
if mnemonic.word_count() != BIP39_WORD_COUNT {
return Err(UnlockError::AuthFailed);
}
Ok(Zeroizing::new(mnemonic.to_entropy()))
}
}
impl UnlockMethod for Bip39Method {
fn kind(&self) -> MethodKind {
MethodKind::Bip39
}
fn available(&self) -> bool {
!self.phrase.is_empty()
}
fn recover_kek(&self, slot: &Slot, header_aad: &[u8]) -> Result<MasterKek, UnlockError> {
let MethodParams::Bip39 { salt, argon2 } = &slot.params else {
return Err(UnlockError::ParamsMismatch("expected bip39 params".into()));
};
let entropy = self.entropy()?;
let slot_key = derive_slot_key(&entropy, &salt.0, *argon2)?;
let nonce: [u8; NONCE_LEN] = slot
.wrap
.nonce
.0
.as_slice()
.try_into()
.map_err(|_| UnlockError::ParamsMismatch("bad wrap nonce length".into()))?;
let aad = wrap_aad(header_aad, slot.slot_id);
let kek_bytes = aead::open(&slot_key, &nonce, &aad, &slot.wrap.ciphertext.0)
.map_err(|_| UnlockError::AuthFailed)?;
MasterKek::from_slice(&kek_bytes)
.ok_or_else(|| UnlockError::Crypto("unwrapped KEK has wrong length".into()))
}
fn wrap_kek(
&self,
kek: &MasterKek,
header_aad: &[u8],
slot_id: u32,
) -> Result<(MethodParams, KekWrap), UnlockError> {
let mut salt = [0u8; SALT_LEN];
rand::rngs::OsRng.fill_bytes(&mut salt);
let entropy = self.entropy()?;
let slot_key = derive_slot_key(&entropy, &salt, self.argon2)?;
let nonce = aead::fresh_nonce();
let aad = wrap_aad(header_aad, slot_id);
let ciphertext = aead::seal(&slot_key, &nonce, &aad, kek.as_bytes())
.map_err(|e| UnlockError::Crypto(e.to_string()))?;
let params = MethodParams::Bip39 {
salt: B64Bytes(salt.to_vec()),
argon2: self.argon2,
};
let wrap = KekWrap {
nonce: B64Bytes(nonce.to_vec()),
ciphertext: B64Bytes(ciphertext),
};
Ok((params, wrap))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::seal::format::Header;
const FAST: Argon2Params = Argon2Params {
m_cost_kib: 256,
t_cost: 1,
p_cost: 1,
};
fn header() -> Header {
Header {
format_version: 1,
suite: crate::seal::format::Suite::v1(),
bundle_id: [1u8; 16],
created_unix: 0,
epoch: 1,
}
}
#[test]
fn phrase_to_kek_round_trip() {
let phrase = Bip39Method::generate_phrase().unwrap();
let method = Bip39Method::with_params(phrase, FAST);
let kek = MasterKek::generate();
let aad = header().to_aad_bytes().unwrap();
let (params, wrap) = method.wrap_kek(&kek, &aad, 0).unwrap();
let slot = Slot {
slot_id: 0,
method: MethodKind::Bip39,
label: "break-glass".into(),
created_unix: 0,
params,
wrap,
};
let recovered = method.recover_kek(&slot, &aad).unwrap();
assert_eq!(recovered.as_bytes(), kek.as_bytes());
}
#[test]
fn wrong_phrase_fails_closed() {
let good = Bip39Method::generate_phrase().unwrap();
let method = Bip39Method::with_params(good, FAST);
let kek = MasterKek::generate();
let aad = header().to_aad_bytes().unwrap();
let (params, wrap) = method.wrap_kek(&kek, &aad, 0).unwrap();
let slot = Slot {
slot_id: 0,
method: MethodKind::Bip39,
label: "x".into(),
created_unix: 0,
params,
wrap,
};
let other = Bip39Method::generate_phrase().unwrap();
let other_method = Bip39Method::with_params(other, FAST);
assert!(matches!(
other_method.recover_kek(&slot, &aad),
Err(UnlockError::AuthFailed)
));
}
#[test]
fn malformed_phrase_is_auth_failed() {
let method = Bip39Method::with_params(Zeroizing::new("not a valid phrase".into()), FAST);
assert!(method.entropy().is_err());
}
}