use super::entry::EntryId;
use crate::{Error, ExposeSecret, Result, SecretBytes};
use chacha20poly1305::aead::{AeadInOut, KeyInit};
use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
use hkdf::Hkdf;
use sha2::Sha256;
use subtle::ConstantTimeEq as _;
pub const MASTER_KEY_LEN: usize = 32;
pub struct MasterKey(SecretBytes);
impl MasterKey {
pub fn generate() -> Result<Self> {
let mut bytes = [0_u8; MASTER_KEY_LEN];
crate::getrandom::fill(&mut bytes).map_err(|_| Error::RandomnessUnavailable)?;
Ok(Self(Vec::from(bytes).into()))
}
pub fn from_bytes(bytes: SecretBytes) -> Result<Self> {
if bytes.expose_secret().len() != MASTER_KEY_LEN {
return Err(Error::MasterKeyWrongLength {
expected: MASTER_KEY_LEN,
actual: bytes.expose_secret().len(),
});
}
Ok(Self(bytes))
}
#[must_use]
pub fn expose(&self) -> &[u8] {
self.0.expose_secret()
}
}
impl core::fmt::Debug for MasterKey {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str("MasterKey([REDACTED])")
}
}
#[must_use]
pub fn master_key_to_bytes(key: &MasterKey) -> SecretBytes {
key.0.clone()
}
pub const NONCE_LEN: usize = 24;
pub const TAG_LEN: usize = 16;
pub type Nonce = [u8; NONCE_LEN];
pub(crate) fn random_nonce() -> Result<Nonce> {
let mut nonce = [0_u8; NONCE_LEN];
crate::getrandom::fill(&mut nonce).map_err(|_| Error::RandomnessUnavailable)?;
Ok(nonce)
}
const HKDF_INFO_ENTRY: &[u8] = b"Qualia2FA vault v1 entry encryption";
const HKDF_SALT: [u8; 32] = [0_u8; 32];
pub(crate) fn entry_key(master: &MasterKey) -> Result<[u8; 32]> {
derive_key(master, HKDF_INFO_ENTRY)
}
pub(crate) fn derive_key(master: &MasterKey, info: &[u8]) -> Result<[u8; 32]> {
derive_key_with_salt(master, &HKDF_SALT, info)
}
pub(crate) fn derive_key_with_salt(
master: &MasterKey,
salt: &[u8],
info: &[u8],
) -> Result<[u8; 32]> {
let hkdf = Hkdf::<Sha256>::new(Some(salt), master.expose());
let mut key = [0_u8; 32];
hkdf.expand(info, &mut key)
.map_err(|_| Error::VaultDecryptionFailed)?;
Ok(key)
}
pub(crate) const HKDF_INFO_SYNC_RECORD: &[u8] = HKDF_INFO_ENTRY;
pub(crate) const HKDF_INFO_SYNC_ENVELOPE: &[u8] = b"Qualia2FA sync v1 envelope";
pub(crate) fn seal_bytes(key: &[u8; 32], aad: &[u8], plaintext: &[u8]) -> Result<(Nonce, Vec<u8>)> {
let cipher = XChaCha20Poly1305::new(&Key::from(*key));
let nonce = random_nonce()?;
let mut buffer = plaintext.to_vec();
cipher
.encrypt_in_place(&XNonce::from(nonce), aad, &mut buffer)
.map_err(|_| Error::VaultDecryptionFailed)?;
Ok((nonce, buffer))
}
pub(crate) fn open_bytes(
key: &[u8; 32],
aad: &[u8],
nonce: &Nonce,
ciphertext: &[u8],
) -> Result<Vec<u8>> {
let cipher = XChaCha20Poly1305::new(&Key::from(*key));
let mut buffer = ciphertext.to_vec();
cipher
.decrypt_in_place(&XNonce::from(*nonce), aad, &mut buffer)
.map_err(|_| Error::VaultDecryptionFailed)?;
Ok(buffer)
}
pub const KEY_CHECK_LEN: usize = 32;
const HKDF_INFO_KEY_CHECK: &[u8] = b"Qualia2FA vault v1 master key check";
#[must_use]
pub fn key_check(master: &MasterKey) -> [u8; KEY_CHECK_LEN] {
let hkdf = Hkdf::<Sha256>::new(Some(&HKDF_SALT), master.expose());
let mut check = [0_u8; KEY_CHECK_LEN];
hkdf.expand(HKDF_INFO_KEY_CHECK, &mut check)
.expect("a 32-byte HKDF output cannot fail");
check
}
pub fn verify_key_check(master: &MasterKey, stored: &[u8]) -> Result<()> {
let stored: &[u8; KEY_CHECK_LEN] = stored.try_into().map_err(|_| Error::VaultBadKeyCheck)?;
if key_check(master).ct_eq(stored).into() {
Ok(())
} else {
Err(Error::VaultDecryptionFailed)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RecordKind {
Entry,
Tombstone,
Group,
SyncEntry,
SyncTombstone,
SyncGroup,
}
impl RecordKind {
pub(crate) const fn as_byte(self) -> u8 {
match self {
Self::Entry => 0x01,
Self::Tombstone => 0x02,
Self::Group => 0x03,
Self::SyncEntry => 0x81,
Self::SyncTombstone => 0x82,
Self::SyncGroup => 0x83,
}
}
pub(crate) const fn from_byte(byte: u8) -> Option<Self> {
match byte {
0x01 => Some(Self::Entry),
0x02 => Some(Self::Tombstone),
0x03 => Some(Self::Group),
_ => None,
}
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "defined by contract, used by the compile-time assertions and tests of sync; payload parsing tries each kind instead of coming through here"
)
)]
pub(crate) const fn from_sync_byte(byte: u8) -> Option<Self> {
match byte {
0x81 => Some(Self::SyncEntry),
0x82 => Some(Self::SyncTombstone),
0x83 => Some(Self::SyncGroup),
_ => None,
}
}
}
const AAD_LEN: usize = 1 + EntryId::LEN + 1;
pub(crate) fn build_aad(
kind: RecordKind,
record_id: &[u8; EntryId::LEN],
format_version: u8,
) -> [u8; AAD_LEN] {
let mut aad = [0_u8; AAD_LEN];
aad[0] = kind.as_byte();
aad[1..1 + EntryId::LEN].copy_from_slice(record_id);
aad[AAD_LEN - 1] = format_version;
aad
}
pub(crate) fn seal(
master: &MasterKey,
kind: RecordKind,
record_id: &[u8; EntryId::LEN],
format_version: u8,
plaintext: &[u8],
) -> Result<(Nonce, Vec<u8>)> {
let key = entry_key(master)?;
seal_with_key(&key, kind, record_id, format_version, plaintext)
}
pub(crate) fn seal_with_key(
key: &[u8; 32],
kind: RecordKind,
record_id: &[u8; EntryId::LEN],
format_version: u8,
plaintext: &[u8],
) -> Result<(Nonce, Vec<u8>)> {
let cipher = XChaCha20Poly1305::new(&Key::from(*key));
let nonce = random_nonce()?;
let aad = build_aad(kind, record_id, format_version);
let mut buffer = plaintext.to_vec();
cipher
.encrypt_in_place(&XNonce::from(nonce), &aad, &mut buffer)
.map_err(|_| Error::VaultDecryptionFailed)?;
Ok((nonce, buffer))
}
pub(crate) fn open(
master: &MasterKey,
kind: RecordKind,
record_id: &[u8; EntryId::LEN],
format_version: u8,
nonce: &Nonce,
ciphertext: &[u8],
) -> Result<Vec<u8>> {
let key = entry_key(master)?;
open_with_key(&key, kind, record_id, format_version, nonce, ciphertext)
}
pub(crate) fn open_with_key(
key: &[u8; 32],
kind: RecordKind,
record_id: &[u8; EntryId::LEN],
format_version: u8,
nonce: &Nonce,
ciphertext: &[u8],
) -> Result<Vec<u8>> {
let cipher = XChaCha20Poly1305::new(&Key::from(*key));
let aad = build_aad(kind, record_id, format_version);
let mut buffer = ciphertext.to_vec();
cipher
.decrypt_in_place(&XNonce::from(*nonce), &aad, &mut buffer)
.map_err(|_| Error::VaultDecryptionFailed)?;
Ok(buffer)
}
#[cfg(test)]
mod tests {
use super::*;
fn key() -> MasterKey {
MasterKey::generate().expect("OS randomness available")
}
#[test]
fn master_key_is_32_bytes_and_debug_is_redacted() {
let k = key();
assert_eq!(k.expose().len(), MASTER_KEY_LEN);
let debug = format!("{k:?}");
assert!(debug.contains("REDACTED"), "Debug is not redacted: {debug}");
for byte in k.expose() {
assert!(
!debug.contains(&format!("{byte}")),
"Debug leaked key bytes: {debug}"
);
}
}
#[test]
fn master_key_rejects_wrong_length() {
for len in [0_usize, 1, 31, 33, 64] {
let bytes: SecretBytes = vec![0_u8; len].into();
match MasterKey::from_bytes(bytes) {
Err(Error::MasterKeyWrongLength { expected, actual }) => {
assert_eq!(expected, MASTER_KEY_LEN);
assert_eq!(actual, len);
}
other => panic!("length {len} must be rejected, actual ok={}", other.is_ok()),
}
}
}
#[test]
fn master_key_accepts_exactly_32_bytes() {
let bytes: SecretBytes = vec![7_u8; MASTER_KEY_LEN].into();
let k = MasterKey::from_bytes(bytes).expect("correct length");
assert_eq!(k.expose(), &[7_u8; MASTER_KEY_LEN]);
}
#[test]
fn derived_key_is_deterministic() {
let bytes: SecretBytes = vec![3_u8; MASTER_KEY_LEN].into();
let a = MasterKey::from_bytes(bytes.clone()).expect("correct length");
let b = MasterKey::from_bytes(bytes).expect("correct length");
assert_eq!(
entry_key(&a).expect("derivation succeeds"),
entry_key(&b).expect("derivation succeeds")
);
}
#[test]
fn derived_key_differs_per_master_key() {
let a = MasterKey::from_bytes(vec![1_u8; MASTER_KEY_LEN].into()).expect("correct length");
let b = MasterKey::from_bytes(vec![2_u8; MASTER_KEY_LEN].into()).expect("correct length");
assert_ne!(
entry_key(&a).expect("derivation succeeds"),
entry_key(&b).expect("derivation succeeds")
);
}
#[test]
fn derived_key_is_not_the_master_key() {
let bytes: SecretBytes = vec![5_u8; MASTER_KEY_LEN].into();
let k = MasterKey::from_bytes(bytes).expect("correct length");
assert_ne!(
entry_key(&k).expect("derivation succeeds"),
*k.expose().first_chunk::<32>().expect("32 bytes")
);
}
#[test]
fn seal_then_open_roundtrips() {
let k = key();
let id = EntryId::generate().expect("OS randomness available");
let plaintext = b"the quick brown fox";
let (nonce, ciphertext) =
seal(&k, RecordKind::Entry, id.as_bytes(), 1, plaintext).expect("sealing succeeds");
assert_eq!(ciphertext.len(), plaintext.len() + TAG_LEN);
assert_ne!(
&ciphertext[..plaintext.len()],
&plaintext[..],
"the ciphertext equals the plaintext"
);
let opened = open(&k, RecordKind::Entry, id.as_bytes(), 1, &nonce, &ciphertext)
.expect("opening succeeds");
assert_eq!(opened, plaintext);
}
#[test]
fn each_seal_uses_a_fresh_nonce() {
let k = key();
let id = EntryId::generate().expect("OS randomness available");
let (n1, _) =
seal(&k, RecordKind::Entry, id.as_bytes(), 1, b"same").expect("sealing succeeds");
let (n2, _) =
seal(&k, RecordKind::Entry, id.as_bytes(), 1, b"same").expect("sealing succeeds");
assert_ne!(n1, n2, "two encryptions reused a nonce");
}
#[test]
fn ciphertext_is_not_deterministic() {
let k = key();
let id = EntryId::generate().expect("OS randomness available");
let (_, c1) =
seal(&k, RecordKind::Entry, id.as_bytes(), 1, b"same").expect("sealing succeeds");
let (_, c2) =
seal(&k, RecordKind::Entry, id.as_bytes(), 1, b"same").expect("sealing succeeds");
assert_ne!(c1, c2);
}
#[test]
fn wrong_master_key_fails() {
let id = EntryId::generate().expect("OS randomness available");
let (nonce, ciphertext) =
seal(&key(), RecordKind::Entry, id.as_bytes(), 1, b"secret").expect("sealing succeeds");
let err = open(
&key(),
RecordKind::Entry,
id.as_bytes(),
1,
&nonce,
&ciphertext,
);
assert!(matches!(err, Err(Error::VaultDecryptionFailed)));
}
#[test]
fn ciphertext_cannot_move_between_entry_ids() {
let k = key();
let a = EntryId::generate().expect("OS randomness available");
let b = EntryId::generate().expect("OS randomness available");
let (nonce, ciphertext) =
seal(&k, RecordKind::Entry, a.as_bytes(), 1, b"payload").expect("sealing succeeds");
assert!(open(&k, RecordKind::Entry, a.as_bytes(), 1, &nonce, &ciphertext).is_ok());
let err = open(&k, RecordKind::Entry, b.as_bytes(), 1, &nonce, &ciphertext);
assert!(
matches!(err, Err(Error::VaultDecryptionFailed)),
"the ciphertext was moved to another entry yet still opens"
);
}
#[test]
fn ciphertext_cannot_move_between_kinds() {
let k = key();
let id = EntryId::generate().expect("OS randomness available");
let (nonce, ciphertext) =
seal(&k, RecordKind::Entry, id.as_bytes(), 1, b"payload").expect("sealing succeeds");
assert!(
open(
&k,
RecordKind::Tombstone,
id.as_bytes(),
1,
&nonce,
&ciphertext
)
.is_err()
);
let (nonce2, ciphertext2) = seal(&k, RecordKind::Tombstone, id.as_bytes(), 1, b"payload")
.expect("sealing succeeds");
assert!(
open(
&k,
RecordKind::Entry,
id.as_bytes(),
1,
&nonce2,
&ciphertext2
)
.is_err()
);
}
#[test]
fn ciphertext_cannot_move_between_format_versions() {
let k = key();
let id = EntryId::generate().expect("OS randomness available");
let (nonce, ciphertext) =
seal(&k, RecordKind::Entry, id.as_bytes(), 1, b"payload").expect("sealing succeeds");
assert!(open(&k, RecordKind::Entry, id.as_bytes(), 2, &nonce, &ciphertext).is_err());
}
#[test]
fn flipping_any_ciphertext_byte_fails_authentication() {
let k = key();
let id = EntryId::generate().expect("OS randomness available");
let plaintext = b"0123456789abcdef0123456789abcdef";
let (nonce, ciphertext) =
seal(&k, RecordKind::Entry, id.as_bytes(), 1, plaintext).expect("sealing succeeds");
for index in 0..ciphertext.len() {
let mut tampered = ciphertext.clone();
tampered[index] ^= 0x01;
let err = open(&k, RecordKind::Entry, id.as_bytes(), 1, &nonce, &tampered);
assert!(
matches!(err, Err(Error::VaultDecryptionFailed)),
"ciphertext byte {index} was flipped yet still authenticates"
);
}
for index in 0..NONCE_LEN {
let mut tampered = nonce;
tampered[index] ^= 0x01;
let err = open(
&k,
RecordKind::Entry,
id.as_bytes(),
1,
&tampered,
&ciphertext,
);
assert!(
matches!(err, Err(Error::VaultDecryptionFailed)),
"nonce byte {index} was flipped yet still authenticates"
);
}
}
#[test]
fn aad_layout_is_stable() {
let id = EntryId::from_bytes([0xAB; EntryId::LEN]);
let aad = build_aad(RecordKind::Entry, id.as_bytes(), 1);
assert_eq!(aad.len(), AAD_LEN);
assert_eq!(aad[0], 0x01, "kind byte");
assert_eq!(&aad[1..17], &[0xAB; 16], "entry_id must follow immediately");
assert_eq!(aad[17], 1, "the format version must be the last byte");
assert_eq!(build_aad(RecordKind::Tombstone, id.as_bytes(), 7)[0], 0x02);
assert_eq!(build_aad(RecordKind::Tombstone, id.as_bytes(), 7)[17], 7);
}
#[test]
fn record_kind_byte_roundtrips_and_rejects_unknown() {
for (kind, byte) in [
(RecordKind::Entry, 0x01),
(RecordKind::Tombstone, 0x02),
(RecordKind::Group, 0x03),
] {
assert_eq!(
kind.as_byte(),
byte,
"the file-format kind bytes are a contract"
);
assert_eq!(RecordKind::from_byte(byte), Some(kind));
}
for unknown in [0_u8, 4, 0xFF] {
assert_eq!(RecordKind::from_byte(unknown), None);
}
for sync in [RecordKind::SyncEntry, RecordKind::SyncTombstone] {
assert_eq!(
RecordKind::from_byte(sync.as_byte()),
None,
"a sync kind must never be accepted inside a vault file"
);
}
}
#[test]
fn empty_plaintext_roundtrips() {
let k = key();
let id = EntryId::generate().expect("OS randomness available");
let (nonce, ciphertext) =
seal(&k, RecordKind::Entry, id.as_bytes(), 1, b"").expect("sealing succeeds");
assert_eq!(
ciphertext.len(),
TAG_LEN,
"empty plaintext leaves only the tag"
);
let opened = open(&k, RecordKind::Entry, id.as_bytes(), 1, &nonce, &ciphertext)
.expect("opening succeeds");
assert!(opened.is_empty());
}
#[test]
fn truncated_ciphertext_fails() {
let k = key();
let id = EntryId::generate().expect("OS randomness available");
let (nonce, ciphertext) =
seal(&k, RecordKind::Entry, id.as_bytes(), 1, b"payload").expect("sealing succeeds");
for cut in 1..=ciphertext.len() {
let truncated = &ciphertext[..ciphertext.len() - cut];
let err = open(&k, RecordKind::Entry, id.as_bytes(), 1, &nonce, truncated);
assert!(
matches!(err, Err(Error::VaultDecryptionFailed)),
"truncating {cut} bytes still authenticates"
);
}
}
#[test]
fn master_key_bytes_roundtrip() {
let original =
MasterKey::from_bytes(vec![9_u8; MASTER_KEY_LEN].into()).expect("correct length");
let bytes = master_key_to_bytes(&original);
let rebuilt = MasterKey::from_bytes(bytes).expect("correct length");
assert_eq!(original.expose(), rebuilt.expose());
}
}