use crate::util::DetRng;
use hmac::{Hmac, KeyInit, Mac};
use sha2::{Digest, Sha256};
use std::fmt;
use zeroize::ZeroizeOnDrop;
type HmacSha256 = Hmac<Sha256>;
pub const AUTH_KEY_SIZE: usize = 32;
pub const MIN_DISTINCT_BYTES: usize = 16;
pub const MIN_HAMMING_WEIGHT: u32 = 64;
pub const MAX_HAMMING_WEIGHT: u32 = 192;
pub const MAX_BYTE_FREQUENCY: usize = 4;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AuthKeyError {
#[error("auth key rejected: {reason}")]
WeakKey {
reason: WeakKeyReason,
},
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum WeakKeyReason {
#[error(
"insufficient byte diversity: only {distinct} distinct byte values out of 32 (minimum {minimum})"
)]
InsufficientByteDiversity {
distinct: usize,
minimum: usize,
},
#[error(
"extreme Hamming weight: {weight} 1-bits out of 256 (acceptable range [{minimum}, {maximum}])"
)]
ExtremeHammingWeight {
weight: u32,
minimum: u32,
maximum: u32,
},
#[error(
"excessive byte concentration: byte value {byte_value} appears {frequency} times (maximum {maximum})"
)]
ExcessiveByteConcentration {
byte_value: u8,
frequency: usize,
maximum: usize,
},
}
#[derive(Clone, PartialEq, Eq, Hash, ZeroizeOnDrop)]
pub struct AuthKey {
bytes: [u8; AUTH_KEY_SIZE],
}
impl AuthKey {
#[must_use]
pub fn from_seed(seed: u64) -> Self {
let mut hasher = Sha256::new();
hasher.update(b"asupersync::security::AuthKey::from_seed:v1");
hasher.update(seed.to_le_bytes());
let bytes: [u8; AUTH_KEY_SIZE] = hasher.finalize().into();
match Self::from_bytes(bytes) {
Ok(key) => key,
Err(_) => {
Self::from_hkdf(
&seed.to_le_bytes(),
Some(b"backup-salt"),
b"asupersync::AuthKey::strengthened",
)
}
}
}
#[must_use]
pub fn from_rng(rng: &mut DetRng) -> Self {
let mut bytes = [0u8; AUTH_KEY_SIZE];
rng.fill_bytes(&mut bytes);
match Self::from_bytes(bytes) {
Ok(key) => key,
Err(err) => {
Self::from_hkdf(&bytes, Some(b"rng-strengthen-salt"), b"asupersync::AuthKey::rng-strengthened")
.try_validate()
.unwrap_or_else(|_| {
panic!("Critical security failure: Unable to generate strong key even with HKDF strengthening. Original error: {:?}", err)
})
}
}
}
#[inline]
pub fn from_bytes(bytes: [u8; AUTH_KEY_SIZE]) -> Result<Self, AuthKeyError> {
let mut byte_counts = [0u8; 256];
let mut distinct = 0usize;
for &b in bytes.iter() {
let idx = b as usize;
if byte_counts[idx] == 0 {
distinct += 1;
}
byte_counts[idx] = byte_counts[idx].saturating_add(1);
if byte_counts[idx] > MAX_BYTE_FREQUENCY as u8 {
return Err(AuthKeyError::WeakKey {
reason: WeakKeyReason::ExcessiveByteConcentration {
byte_value: b,
frequency: byte_counts[idx] as usize,
maximum: MAX_BYTE_FREQUENCY,
},
});
}
}
if distinct < MIN_DISTINCT_BYTES {
return Err(AuthKeyError::WeakKey {
reason: WeakKeyReason::InsufficientByteDiversity {
distinct,
minimum: MIN_DISTINCT_BYTES,
},
});
}
let hamming: u32 = bytes.iter().map(|b| b.count_ones()).sum();
if !(MIN_HAMMING_WEIGHT..=MAX_HAMMING_WEIGHT).contains(&hamming) {
return Err(AuthKeyError::WeakKey {
reason: WeakKeyReason::ExtremeHammingWeight {
weight: hamming,
minimum: MIN_HAMMING_WEIGHT,
maximum: MAX_HAMMING_WEIGHT,
},
});
}
Ok(Self { bytes })
}
#[inline]
#[must_use]
pub const fn as_bytes(&self) -> &[u8; AUTH_KEY_SIZE] {
&self.bytes
}
#[must_use]
pub fn derive_subkey(&self, purpose: &[u8]) -> Self {
let mut mac = HmacSha256::new_from_slice(&self.bytes).expect("HMAC accepts any key length");
mac.update(purpose);
let result = mac.finalize().into_bytes();
Self {
bytes: result.into(),
}
}
#[must_use]
pub fn derive_with_salt(&self, salt: &[u8], context: &[u8]) -> Self {
let mut extract_mac =
HmacSha256::new_from_slice(salt).expect("HMAC accepts any key length");
extract_mac.update(&self.bytes);
let prk = extract_mac.finalize().into_bytes();
let mut expand_mac = HmacSha256::new_from_slice(&prk).expect("HMAC accepts any key length");
expand_mac.update(context);
let result = expand_mac.finalize().into_bytes();
Self {
bytes: result.into(),
}
}
pub fn from_hmac_derived(bytes: [u8; AUTH_KEY_SIZE]) -> Result<Self, AuthKeyError> {
Ok(Self { bytes })
}
#[must_use]
pub fn from_hkdf(ikm: &[u8], salt: Option<&[u8]>, info: &[u8]) -> Self {
const ZERO_SALT: [u8; AUTH_KEY_SIZE] = [0; AUTH_KEY_SIZE];
let mut extract_mac = HmacSha256::new_from_slice(salt.unwrap_or(&ZERO_SALT))
.expect("HMAC accepts any key length");
extract_mac.update(ikm);
let prk = extract_mac.finalize().into_bytes();
let mut expand_mac = HmacSha256::new_from_slice(&prk).expect("HMAC accepts any key length");
expand_mac.update(info);
expand_mac.update(&[1]);
let result = expand_mac.finalize().into_bytes();
let mut okm = [0u8; AUTH_KEY_SIZE];
okm.copy_from_slice(&result[..AUTH_KEY_SIZE]);
Self { bytes: okm }
}
fn try_validate(&self) -> Result<Self, AuthKeyError> {
Self::from_bytes(self.bytes)
}
}
impl fmt::Debug for AuthKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("AuthKey(<redacted>)")
}
}
#[derive(Clone, Debug)]
pub struct KeyRing {
pub active: AuthKey,
pub retired: Option<AuthKey>,
}
impl KeyRing {
#[must_use]
pub fn new(active: AuthKey) -> Self {
Self {
active,
retired: None,
}
}
pub fn rotate(&mut self, new: AuthKey) {
let prior = std::mem::replace(&mut self.active, new);
self.retired = Some(prior);
}
pub fn retire(&mut self) {
self.retired = None;
}
#[must_use]
pub fn verify(&self, msg: &[u8], sig: &[u8]) -> bool {
let active_matches = Self::verify_with_key(&self.active, msg, sig);
let retired_matches = match &self.retired {
Some(retired) => Self::verify_with_key(retired, msg, sig),
None => false,
};
active_matches | retired_matches
}
fn verify_with_key(key: &AuthKey, msg: &[u8], sig: &[u8]) -> bool {
let mut mac =
HmacSha256::new_from_slice(key.as_bytes()).expect("HMAC accepts any key length");
mac.update(msg);
mac.verify_slice(sig).is_ok()
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::pedantic,
clippy::nursery,
clippy::expect_fun_call,
clippy::map_unwrap_or,
clippy::cast_possible_wrap,
clippy::future_not_send
)]
use super::*;
use hmac::{Hmac, KeyInit, Mac};
use sha1::Sha1;
fn hotp_dynamic_truncation(mac: &[u8], digits: u32) -> u32 {
let offset = usize::from(mac[mac.len() - 1] & 0x0f);
let binary = ((u32::from(mac[offset]) & 0x7f) << 24)
| (u32::from(mac[offset + 1]) << 16)
| (u32::from(mac[offset + 2]) << 8)
| u32::from(mac[offset + 3]);
binary % 10_u32.pow(digits)
}
#[test]
fn test_from_seed_deterministic() {
let k1 = AuthKey::from_seed(42);
let k2 = AuthKey::from_seed(42);
assert_eq!(k1, k2);
}
#[test]
fn test_from_seed_different_seeds() {
let k1 = AuthKey::from_seed(1);
let k2 = AuthKey::from_seed(2);
assert_ne!(k1, k2);
}
#[test]
fn test_from_seed_zero_is_distinct() {
let k0 = AuthKey::from_seed(0);
let k1 = AuthKey::from_seed(1);
assert_ne!(k0, k1);
}
#[test]
fn test_from_seed_zero_does_not_collide_with_legacy_magic_seed() {
let zero = AuthKey::from_seed(0);
let legacy_magic = AuthKey::from_seed(0x9e37_79b9_7f4a_7c15);
assert_ne!(zero, legacy_magic);
}
#[test]
fn test_from_rng_produces_unique_keys() {
let mut rng = DetRng::new(123);
let k1 = AuthKey::from_rng(&mut rng);
let k2 = AuthKey::from_rng(&mut rng);
assert_ne!(k1, k2);
}
#[test]
fn test_from_bytes_roundtrip() {
let mut bytes = [0u8; AUTH_KEY_SIZE];
for (i, b) in bytes.iter_mut().enumerate() {
*b = i as u8;
}
let key = AuthKey::from_bytes(bytes).expect("strong key accepted");
assert_eq!(key.as_bytes(), &bytes);
}
#[test]
fn from_bytes_rejects_weak_inputs() {
let err = AuthKey::from_bytes([0u8; AUTH_KEY_SIZE]).expect_err("all-zero rejected");
assert!(matches!(
err,
AuthKeyError::WeakKey {
reason: WeakKeyReason::InsufficientByteDiversity { distinct: 1, .. }
}
));
let err = AuthKey::from_bytes([0xFFu8; AUTH_KEY_SIZE]).expect_err("all-FF rejected");
assert!(matches!(
err,
AuthKeyError::WeakKey {
reason: WeakKeyReason::InsufficientByteDiversity { distinct: 1, .. }
}
));
let err = AuthKey::from_bytes([42u8; AUTH_KEY_SIZE]).expect_err("[42; 32] rejected");
assert!(matches!(
err,
AuthKeyError::WeakKey {
reason: WeakKeyReason::InsufficientByteDiversity { distinct: 1, .. }
}
));
let mut weak = [0u8; AUTH_KEY_SIZE];
for (i, b) in weak.iter_mut().enumerate() {
*b = (i % 7) as u8;
}
let err = AuthKey::from_bytes(weak).expect_err("7-distinct rejected");
assert!(matches!(
err,
AuthKeyError::WeakKey {
reason: WeakKeyReason::InsufficientByteDiversity { distinct: 7, .. }
}
));
}
#[test]
fn test_derive_subkey_deterministic() {
let key = AuthKey::from_seed(100);
let sub1 = key.derive_subkey(b"transport");
let sub2 = key.derive_subkey(b"transport");
assert_eq!(sub1, sub2);
}
#[test]
fn test_derive_subkey_different_purposes() {
let key = AuthKey::from_seed(100);
let sub1 = key.derive_subkey(b"transport");
let sub2 = key.derive_subkey(b"storage");
assert_ne!(sub1, sub2);
}
#[test]
fn test_derived_key_not_equal_to_primary() {
let key = AuthKey::from_seed(100);
let sub = key.derive_subkey(b"test");
assert_ne!(key, sub);
}
#[test]
fn test_debug_does_not_leak_key_material() {
let key = AuthKey::from_seed(0);
let prefix = format!("{:02x}{:02x}", key.bytes[0], key.bytes[1]);
let debug = format!("{key:?}");
assert_eq!(debug, "AuthKey(<redacted>)");
assert!(
!debug.contains(&prefix),
"Debug must not expose even a key prefix"
);
}
#[test]
fn auth_key_clone_hash_eq() {
use std::collections::HashSet;
let k1 = AuthKey::from_seed(1);
let k2 = AuthKey::from_seed(2);
let copied = k1.clone();
let cloned = k1.clone();
assert_eq!(copied, cloned);
assert_ne!(k1, k2);
let mut set = HashSet::new();
set.insert(k1.clone());
set.insert(k2.clone());
assert_eq!(set.len(), 2);
assert!(set.contains(&k1));
}
#[test]
fn derive_subkey_matches_rfc6238_sha256_time_59_vector() {
let secret = *b"12345678901234567890123456789012";
let key = AuthKey::from_bytes(secret).expect("RFC 6238 vector accepted");
let moving_factor = 1u64.to_be_bytes();
let mac = key.derive_subkey(&moving_factor);
let totp = hotp_dynamic_truncation(mac.as_bytes(), 8);
assert_eq!(totp, 46_119_246);
}
#[test]
#[allow(unsafe_code)]
fn drop_zeroises_key_bytes() {
use std::mem::ManuallyDrop;
let mut key = ManuallyDrop::new(AuthKey::from_seed(0xDEAD_BEEF));
let bytes_ptr: *const [u8; AUTH_KEY_SIZE] = std::ptr::addr_of!(key.bytes);
let pre = unsafe { *bytes_ptr };
assert!(
pre.iter().any(|&b| b != 0),
"from_seed must produce non-zero bytes pre-drop"
);
unsafe {
ManuallyDrop::drop(&mut key);
}
let post = unsafe { *bytes_ptr };
assert!(
post.iter().all(|&b| b == 0),
"Drop must zeroise every key byte; observed: {post:02x?}"
);
}
#[test]
fn auth_key_is_not_copy() {
fn is_copy<T: Copy>() {}
let _ = is_copy::<u8>; let k1 = AuthKey::from_seed(1);
let k2 = k1.clone();
assert_eq!(k1, k2);
}
#[test]
fn hotp_matches_rfc4226_counter_0_golden_vector() {
type HmacSha1 = Hmac<Sha1>;
let secret = b"12345678901234567890";
let counter = 0u64.to_be_bytes();
let mut mac = HmacSha1::new_from_slice(secret).expect("HMAC accepts any key length");
mac.update(&counter);
let digest = mac.finalize().into_bytes();
let hotp = hotp_dynamic_truncation(&digest, 6);
assert_eq!(hotp, 755_224);
}
fn hmac_sign(key: &AuthKey, msg: &[u8]) -> Vec<u8> {
let mut mac =
HmacSha256::new_from_slice(key.as_bytes()).expect("HMAC accepts any key length");
mac.update(msg);
mac.finalize().into_bytes().to_vec()
}
#[test]
fn key_ring_new_only_active_verifies() {
let k = AuthKey::from_seed(1);
let ring = KeyRing::new(k.clone());
let sig = hmac_sign(&k, b"hello");
assert!(ring.verify(b"hello", &sig));
let other = AuthKey::from_seed(2);
let bad_sig = hmac_sign(&other, b"hello");
assert!(!ring.verify(b"hello", &bad_sig));
assert!(ring.retired.is_none());
}
#[test]
fn key_ring_rotate_accepts_old_and_new() {
let old = AuthKey::from_seed(10);
let new = AuthKey::from_seed(20);
let mut ring = KeyRing::new(old.clone());
let old_sig = hmac_sign(&old, b"in_flight");
let new_sig = hmac_sign(&new, b"fresh");
ring.rotate(new.clone());
assert!(
ring.verify(b"in_flight", &old_sig),
"retired key must accept"
);
assert!(ring.verify(b"fresh", &new_sig), "active key must accept");
assert_eq!(ring.active, new);
assert_eq!(ring.retired.as_ref(), Some(&old));
}
#[test]
fn key_ring_retire_drops_retired_slot() {
let old = AuthKey::from_seed(100);
let new = AuthKey::from_seed(200);
let mut ring = KeyRing::new(old.clone());
ring.rotate(new.clone());
ring.retire();
let old_sig = hmac_sign(&old, b"stale");
assert!(!ring.verify(b"stale", &old_sig));
ring.retire();
assert!(ring.retired.is_none());
}
#[test]
fn key_ring_double_rotate_discards_oldest() {
let k1 = AuthKey::from_seed(1);
let k2 = AuthKey::from_seed(2);
let k3 = AuthKey::from_seed(3);
let mut ring = KeyRing::new(k1.clone());
ring.rotate(k2.clone());
ring.rotate(k3.clone());
let k1_sig = hmac_sign(&k1, b"too_old");
assert!(!ring.verify(b"too_old", &k1_sig));
let k2_sig = hmac_sign(&k2, b"recently_retired");
assert!(ring.verify(b"recently_retired", &k2_sig));
let k3_sig = hmac_sign(&k3, b"current");
assert!(ring.verify(b"current", &k3_sig));
}
#[test]
fn test_strengthened_validation_rejects_weak_keys() {
let low_diversity = [0u8; 32]; match AuthKey::from_bytes(low_diversity) {
Err(AuthKeyError::WeakKey {
reason: WeakKeyReason::InsufficientByteDiversity { distinct, minimum },
}) => {
assert_eq!(distinct, 1);
assert_eq!(minimum, MIN_DISTINCT_BYTES);
}
_ => panic!("Expected InsufficientByteDiversity error"),
}
let low_hamming = [1u8; 32]; match AuthKey::from_bytes(low_hamming) {
Err(AuthKeyError::WeakKey {
reason: WeakKeyReason::ExtremeHammingWeight { weight, .. },
}) => {
assert_eq!(weight, 32);
assert!(weight < MIN_HAMMING_WEIGHT);
}
_ => panic!("Expected ExtremeHammingWeight error for low weight"),
}
let high_hamming = [0xFFu8; 32]; match AuthKey::from_bytes(high_hamming) {
Err(AuthKeyError::WeakKey {
reason: WeakKeyReason::ExtremeHammingWeight { weight, .. },
}) => {
assert_eq!(weight, 256);
assert!(weight > MAX_HAMMING_WEIGHT);
}
_ => panic!("Expected ExtremeHammingWeight error for high weight"),
}
let mut concentrated = [0u8; 32];
concentrated[0..5].fill(42); concentrated[5..].fill(1); match AuthKey::from_bytes(concentrated) {
Err(AuthKeyError::WeakKey {
reason:
WeakKeyReason::ExcessiveByteConcentration {
byte_value,
frequency,
..
},
}) => {
assert_eq!(byte_value, 42);
assert_eq!(frequency, 5);
}
_ => panic!("Expected ExcessiveByteConcentration error"),
}
}
#[test]
fn test_strengthened_key_derivation() {
let master_key = AuthKey::from_seed(42);
let derived1 = master_key.derive_with_salt(b"salt1", b"test-purpose");
let derived2 = master_key.derive_with_salt(b"salt1", b"test-purpose");
let derived3 = master_key.derive_with_salt(b"salt1", b"different-purpose");
let derived4 = master_key.derive_with_salt(b"salt2", b"test-purpose");
assert_eq!(
derived1, derived2,
"Salted derivation should be deterministic"
);
assert_ne!(
derived1, derived3,
"Different contexts should yield different keys"
);
assert_ne!(
derived1, derived4,
"Different salts should yield different keys"
);
let validation_result = AuthKey::from_bytes(*derived1.as_bytes());
assert!(
validation_result.is_ok(),
"Derived key should pass strengthened validation"
);
}
#[test]
fn test_legacy_keys_now_rejected() {
let mut weak_key = [0u8; 32];
weak_key[0] = 0xFF; assert!(
AuthKey::from_bytes(weak_key).is_err(),
"Weak key should be rejected"
);
let mut pattern_key = [0u8; 32];
for i in 0..8 {
pattern_key[i * 4] = i as u8; }
assert!(
AuthKey::from_bytes(pattern_key).is_err(),
"Pattern key should be rejected"
);
}
#[test]
fn test_legitimate_strong_keys_accepted() {
let strong_key = AuthKey::from_seed(12345);
let validation_result = AuthKey::from_bytes(*strong_key.as_bytes());
assert!(
validation_result.is_ok(),
"Strong key should pass validation"
);
let hkdf_key = AuthKey::from_hkdf(b"input-key-material", Some(b"salt"), b"context");
let hkdf_revalidation = AuthKey::from_bytes(*hkdf_key.as_bytes());
assert!(hkdf_revalidation.is_ok(), "HKDF key should pass validation");
}
#[test]
fn test_security_fixes_prevent_weak_key_forgery() {
let strong_from_seed = AuthKey::from_seed(0xDEADBEEF);
assert!(
AuthKey::from_bytes(*strong_from_seed.as_bytes()).is_ok(),
"Normal seeds should produce strong keys"
);
let mut rng = DetRng::new(12345);
let strong_from_rng = AuthKey::from_rng(&mut rng);
assert!(
AuthKey::from_bytes(*strong_from_rng.as_bytes()).is_ok(),
"Normal RNG should produce strong keys"
);
let edge_case_key = AuthKey::from_seed(0);
assert!(
AuthKey::from_bytes(*edge_case_key.as_bytes()).is_ok(),
"Edge case seeds should be strengthened to pass validation"
);
let key1 = AuthKey::from_seed(42);
let key2 = AuthKey::from_seed(42);
assert_eq!(key1, key2, "Same seed should produce same key");
assert!(
AuthKey::from_bytes(*key1.as_bytes()).is_ok(),
"Deterministic keys should still be strong"
);
}
#[test]
fn test_key_entropy_validation_completeness() {
let mut low_diversity = [0u8; 32];
for (i, byte) in low_diversity.iter_mut().enumerate().take(15) {
*byte = i as u8;
} assert!(
AuthKey::from_bytes(low_diversity).is_err(),
"Insufficient byte diversity should be rejected"
);
let low_hamming = [1u8; 32]; assert!(
AuthKey::from_bytes(low_hamming).is_err(),
"Low Hamming weight should be rejected"
);
let high_hamming = [0xFFu8; 32]; assert!(
AuthKey::from_bytes(high_hamming).is_err(),
"High Hamming weight should be rejected"
);
let mut concentrated = [0u8; 32];
concentrated[0..5].fill(42); for (i, byte) in concentrated.iter_mut().enumerate().skip(5) {
*byte = i as u8;
} assert!(
AuthKey::from_bytes(concentrated).is_err(),
"Excessive byte concentration should be rejected"
);
}
}