use std::collections::HashMap;
use std::sync::Arc;
use arc_swap::ArcSwap;
use hmac::{Hmac, Mac};
use jiff::Timestamp;
use sha2::Sha256;
use subtle::ConstantTimeEq;
use tollgate_core::{KeyId, Principal};
use zeroize::Zeroizing;
use crate::verifier::{CredentialVerifier, Verified};
type HmacSha256 = Hmac<Sha256>;
const SECRET_BYTES: usize = 32;
pub struct MintedKey {
pub key_id: KeyId,
pub principal: Principal,
pub digest: [u8; 32],
pub secret: Zeroizing<Vec<u8>>,
}
impl std::fmt::Debug for MintedKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MintedKey")
.field("key_id", &self.key_id)
.field("principal", &self.principal)
.finish_non_exhaustive()
}
}
#[derive(Clone, Copy)]
struct ProjectedKey {
digest: [u8; 32],
not_after: Option<Timestamp>,
}
pub struct HmacRegistry {
secret: Zeroizing<Vec<u8>>,
keys: ArcSwap<HashMap<u128, ProjectedKey>>,
}
impl std::fmt::Debug for HmacRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HmacRegistry")
.field("projected_keys", &self.len())
.finish_non_exhaustive()
}
}
impl HmacRegistry {
#[must_use]
pub fn new(secret: &[u8]) -> Self {
HmacRegistry {
secret: Zeroizing::new(secret.to_vec()),
keys: ArcSwap::from_pointee(HashMap::new()),
}
}
fn digest(&self, credential: &[u8]) -> [u8; 32] {
let mut mac = HmacSha256::new_from_slice(&self.secret).expect("any key length works");
mac.update(credential);
mac.finalize().into_bytes().into()
}
fn fingerprint(digest: &[u8; 32]) -> u128 {
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&digest[..16]);
u128::from_be_bytes(bytes)
}
pub fn digest_credential(&self, credential: &[u8]) -> (Principal, [u8; 32]) {
let digest = self.digest(credential);
(Principal(Self::fingerprint(&digest)), digest)
}
pub fn mint(&self, key_id: KeyId) -> Result<MintedKey, EntropyUnavailable> {
const DIGITS: &[u8; 16] = b"0123456789abcdef";
let mut entropy = Zeroizing::new([0u8; SECRET_BYTES]);
getrandom::fill(entropy.as_mut_slice()).map_err(|_| EntropyUnavailable)?;
let mut secret = Zeroizing::new(Vec::with_capacity(SECRET_BYTES * 2));
for byte in entropy.iter() {
secret.push(DIGITS[usize::from(byte >> 4)]);
secret.push(DIGITS[usize::from(byte & 0x0f)]);
}
let (principal, digest) = self.digest_credential(&secret);
Ok(MintedKey {
key_id,
principal,
digest,
secret,
})
}
pub fn install(
&self,
keys: impl IntoIterator<Item = (Principal, [u8; 32], Option<Timestamp>)>,
) {
let projected: HashMap<u128, ProjectedKey> = keys
.into_iter()
.map(|(principal, digest, not_after)| (principal.0, ProjectedKey { digest, not_after }))
.collect();
self.keys.store(Arc::new(projected));
}
pub fn install_credentials(
&self,
credentials: impl IntoIterator<Item = impl AsRef<[u8]>>,
) -> Vec<Principal> {
let projected: Vec<_> = credentials
.into_iter()
.map(|credential| {
let (principal, digest) = self.digest_credential(credential.as_ref());
(principal, digest, None)
})
.collect();
let principals = projected.iter().map(|(p, _, _)| *p).collect();
self.install(projected);
principals
}
#[must_use]
pub fn len(&self) -> usize {
self.keys.load().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EntropyUnavailable;
impl std::fmt::Display for EntropyUnavailable {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("the operating system could not supply entropy for a new credential")
}
}
impl std::error::Error for EntropyUnavailable {}
impl CredentialVerifier for HmacRegistry {
fn verify(&self, credential: &[u8]) -> Option<Verified> {
let digest = self.digest(credential);
let principal = Self::fingerprint(&digest);
let keys = self.keys.load();
let projected = keys.get(&principal)?;
if projected.digest.ct_eq(&digest).into() {
Some(match projected.not_after {
Some(until) => Verified::until(Principal(principal), until),
None => Verified::indefinite(Principal(principal)),
})
} else {
None
}
}
}
impl crate::CredentialIssuer for HmacRegistry {
fn mint(&self, key_id: KeyId) -> Result<MintedKey, EntropyUnavailable> {
HmacRegistry::mint(self, key_id)
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::disallowed_methods,
reason = "unit tests that build an arbitrary `now` the assertions are relative to; \
no assertion here depends on what the clock actually said"
)]
#[test]
fn credential_diagnostics_never_disclose_issuer_or_customer_secrets() {
let secret = b"fixture-debug-redaction-hmac-secret-108";
let registry = HmacRegistry::new(secret);
let key = registry.mint(KeyId(1)).unwrap();
registry.install([(key.principal, key.digest, None)]);
let diagnostic = format!("{registry:?} {key:?}");
for protected in [
format!("{secret:?}"),
format!("{:?}", *key.secret),
format!("{:?}", key.digest),
] {
assert!(!diagnostic.contains(&protected));
}
assert!(diagnostic.contains("projected_keys: 1"));
}
use super::*;
#[test]
fn a_minted_credential_verifies_as_the_text_it_is_disclosed_as() {
let registry = HmacRegistry::new(b"fixture-presented-form-secret-143");
let key = registry.mint(KeyId(1)).unwrap();
assert_eq!(key.secret.len(), 64);
assert!(
key.secret
.iter()
.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
);
registry.install([(key.principal, key.digest, None)]);
assert_eq!(
registry.verify(&key.secret).map(|v| v.principal),
Some(key.principal)
);
let decoded: Vec<u8> = key
.secret
.chunks(2)
.map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
.collect();
assert!(
registry.verify(&decoded).is_none(),
"the decoded bytes are not the credential"
);
}
fn registry() -> HmacRegistry {
let registry = HmacRegistry::new(b"server-secret");
registry.install_credentials([b"key-one".as_slice()]);
registry
}
#[test]
fn a_registered_credential_verifies_to_a_stable_principal_with_no_expiry() {
let registry = registry();
assert_eq!(registry.verify(b"key-one"), registry.verify(b"key-one"));
let verified = registry.verify(b"key-one").expect("registered");
assert_eq!(
verified.reusable_until, None,
"a server-issued key does not expire on its own; withdrawal travels by snapshot"
);
}
#[test]
fn an_unregistered_or_altered_credential_does_not_verify() {
let registry = registry();
assert_eq!(registry.verify(b"key-two"), None);
assert_eq!(registry.verify(b"key-one "), None);
assert_eq!(registry.verify(b""), None);
}
#[test]
fn the_same_credential_under_a_different_secret_is_a_different_principal() {
let other = HmacRegistry::new(b"a-different-secret");
let elsewhere = other.install_credentials([b"key-one".as_slice()])[0];
assert_ne!(
registry().verify(b"key-one").expect("registered").principal,
elsewhere
);
}
#[test]
fn no_credential_is_retained_in_the_registry() {
let registry = registry();
let credential: &[u8] = b"key-one";
for projected in registry.keys.load().values() {
assert!(
!projected
.digest
.windows(credential.len())
.any(|w| w == credential),
"a stored digest contains the credential it came from"
);
}
assert!(
!registry
.secret
.windows(credential.len())
.any(|w| w == credential),
"the secret contains the credential"
);
}
}