use aes_gcm::{
Aes256Gcm, KeyInit, Nonce,
aead::{Aead, Payload},
};
use data_encoding::BASE64URL_NOPAD;
use hkdf::Hkdf;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use topcoat::cookie::Key;
const PURPOSE: &[u8] = b"tablo two-factor secret v1";
const RECOVERY_PURPOSE: &[u8] = b"tablo two-factor recovery code v1";
const CODE_PURPOSE: &[u8] = b"tablo two-factor mailed code v1";
const NONCE_BYTES: usize = 12;
fn cipher(key: &Key) -> Aes256Gcm {
let mut derived = [0; 32];
Hkdf::<Sha256>::new(None, key.master())
.expand(PURPOSE, &mut derived)
.expect("32 bytes is a valid HKDF-SHA256 output length");
Aes256Gcm::new(&derived.into())
}
fn keyed_hash(key: &Key, purpose: &[u8], binding: &str, value: &str) -> String {
let mut derived = [0; 32];
Hkdf::<Sha256>::new(None, key.master())
.expand(purpose, &mut derived)
.expect("32 bytes is a valid HKDF-SHA256 output length");
let mut mac = <Hmac<Sha256> as KeyInit>::new_from_slice(&derived)
.expect("HMAC takes a key of any length");
mac.update(binding.as_bytes());
mac.update(b"\n");
mac.update(value.as_bytes());
mac.finalize()
.into_bytes()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
pub(super) fn recovery_hash(key: &Key, binding: &str, code: &str) -> String {
keyed_hash(key, RECOVERY_PURPOSE, binding, code)
}
pub(super) fn code_hash(key: &Key, binding: &str, code: &str) -> String {
keyed_hash(key, CODE_PURPOSE, binding, code.trim())
}
pub(super) fn seal(key: &Key, secret: &[u8], aad: &str) -> String {
let mut nonce = [0; NONCE_BYTES];
getrandom::fill(&mut nonce).expect("the two-factor nonce randomness source failed");
let sealed = cipher(key)
.encrypt(
&Nonce::from(nonce),
Payload {
msg: secret,
aad: aad.as_bytes(),
},
)
.expect("AES-GCM seals any secret this small");
let mut stored = nonce.to_vec();
stored.extend(sealed);
BASE64URL_NOPAD.encode(&stored)
}
pub(super) fn open(key: &Key, stored: &str, aad: &str) -> Option<Vec<u8>> {
let stored = BASE64URL_NOPAD.decode(stored.as_bytes()).ok()?;
if stored.len() < NONCE_BYTES {
return None;
}
let (nonce, sealed) = stored.split_at(NONCE_BYTES);
cipher(key)
.decrypt(
&Nonce::try_from(nonce).ok()?,
Payload {
msg: sealed,
aad: aad.as_bytes(),
},
)
.ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_sealed_secret_opens_only_where_it_was_sealed() {
let key = Key::generate();
let sealed = seal(&key, b"secret", "user:/admin");
assert_eq!(
open(&key, &sealed, "user:/admin").as_deref(),
Some(&b"secret"[..])
);
assert_eq!(open(&key, &sealed, "other:/admin"), None);
assert_eq!(open(&Key::generate(), &sealed, "user:/admin"), None);
assert_ne!(seal(&key, b"secret", "user:/admin"), sealed);
assert_eq!(open(&key, "not base64!", "user:/admin"), None);
}
#[test]
fn a_recovery_hash_is_bound_to_its_key_and_row() {
let key = Key::generate();
let hash = recovery_hash(&key, "user:/admin", "abcdefghij");
assert_eq!(recovery_hash(&key, "user:/admin", "abcdefghij"), hash);
assert_ne!(recovery_hash(&key, "other:/admin", "abcdefghij"), hash);
assert_ne!(
recovery_hash(&Key::generate(), "user:/admin", "abcdefghij"),
hash
);
}
#[test]
fn a_code_hash_is_bound_to_its_key_and_row() {
let key = Key::generate();
let hash = code_hash(&key, "user:/admin", "123456");
assert_eq!(code_hash(&key, "user:/admin", " 123456 "), hash);
assert_ne!(code_hash(&key, "other:/admin", "123456"), hash);
assert_ne!(code_hash(&Key::generate(), "user:/admin", "123456"), hash);
}
}