use secrecy::{ExposeSecret, SecretSlice};
use zeroize::Zeroize;
use hmac::{Hmac, Mac};
use sha2::Sha256;
const KEY_BYTES: usize = 64;
const DERIVATION_DOMAIN: &[u8] = b"arcature/kdf/v1";
#[cfg(feature = "crypt")]
pub(crate) const ENCRYPTER_LABEL: &[u8] = b"encrypter";
#[cfg(feature = "signed-urls")]
pub(crate) const URL_SIGNER_LABEL: &[u8] = b"url-signer";
#[non_exhaustive]
pub struct AppKey {
inner: SecretSlice<u8>,
}
impl AppKey {
pub fn from_bytes(bytes: &[u8]) -> Result<Self, AppKeyError> {
if bytes.len() != KEY_BYTES {
return Err(AppKeyError::WrongLength);
}
Ok(Self {
inner: SecretSlice::from(bytes.to_vec()),
})
}
pub fn from_hex(hex: &str) -> Result<Self, AppKeyError> {
let hex = hex.trim();
if hex.is_empty() {
return Err(AppKeyError::Empty);
}
if !hex.len().is_multiple_of(2) {
return Err(AppKeyError::NotHexadecimal);
}
let mut bytes = Vec::with_capacity(hex.len() / 2);
for pair in hex.as_bytes().chunks(2) {
let high = hex_digit(pair[0]).ok_or(AppKeyError::NotHexadecimal)?;
let low = hex_digit(pair[1]).ok_or(AppKeyError::NotHexadecimal)?;
bytes.push((high << 4) | low);
}
let key = Self::from_bytes(&bytes);
bytes.zeroize();
key
}
pub(crate) fn subkey(&self, label: &[u8]) -> SecretSlice<u8> {
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(self.inner.expose_secret())
.expect("HMAC-SHA256 accepts a key of any length");
mac.update(DERIVATION_DOMAIN);
mac.update(&(label.len() as u64).to_be_bytes());
mac.update(label);
let mut derived = mac.finalize().into_bytes();
let subkey = SecretSlice::from(derived.to_vec());
derived.as_mut_slice().zeroize();
subkey
}
}
impl std::fmt::Debug for AppKey {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("AppKey(<redacted 64-byte key>)")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum AppKeyError {
Empty,
NotHexadecimal,
WrongLength,
}
impl std::fmt::Display for AppKeyError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let detail = match self {
Self::Empty => "APP_KEY is empty",
Self::NotHexadecimal => "APP_KEY is not hexadecimal",
Self::WrongLength => "APP_KEY is not 128 hexadecimal characters (64 bytes)",
};
write!(
formatter,
"{detail}; run `arc key:generate` to write a valid one into .env"
)
}
}
fn hex_digit(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
impl std::error::Error for AppKeyError {}
#[cfg(test)]
mod tests {
use super::{AppKey, AppKeyError, KEY_BYTES};
const LABEL: &[u8] = b"a-consumer";
use secrecy::ExposeSecret;
fn key(fill: u8) -> AppKey {
AppKey::from_bytes(&[fill; KEY_BYTES]).expect("64 bytes")
}
#[test]
fn hex_and_bytes_agree() {
let from_hex = AppKey::from_hex(&"4a".repeat(KEY_BYTES)).expect("valid hex");
let from_bytes = key(0x4a);
assert_eq!(
from_hex.subkey(LABEL).expose_secret(),
from_bytes.subkey(LABEL).expose_secret()
);
}
#[test]
fn case_does_not_change_the_key() {
let lower = AppKey::from_hex(&"ab".repeat(KEY_BYTES)).expect("lower");
let upper = AppKey::from_hex(&"AB".repeat(KEY_BYTES)).expect("upper");
assert_eq!(
lower.subkey(LABEL).expose_secret(),
upper.subkey(LABEL).expose_secret()
);
}
#[test]
fn surrounding_whitespace_is_trimmed() {
let padded = format!(" {}\n", "4a".repeat(KEY_BYTES));
assert!(AppKey::from_hex(&padded).is_ok());
}
#[test]
fn a_short_key_is_refused() {
assert_eq!(
AppKey::from_bytes(&[0u8; 32]).unwrap_err(),
AppKeyError::WrongLength
);
assert_eq!(
AppKey::from_hex(&"4a".repeat(32)).unwrap_err(),
AppKeyError::WrongLength
);
}
#[test]
fn an_odd_number_of_digits_is_not_hexadecimal() {
let odd = "4".repeat(127);
assert_eq!(
AppKey::from_hex(&odd).unwrap_err(),
AppKeyError::NotHexadecimal
);
}
#[test]
fn a_non_hexadecimal_character_is_refused() {
assert_eq!(
AppKey::from_hex(&"zz".repeat(KEY_BYTES)).unwrap_err(),
AppKeyError::NotHexadecimal
);
assert_eq!(
AppKey::from_hex(&"+4".repeat(KEY_BYTES)).unwrap_err(),
AppKeyError::NotHexadecimal
);
assert_eq!(
AppKey::from_hex(&"-4".repeat(KEY_BYTES)).unwrap_err(),
AppKeyError::NotHexadecimal
);
assert_eq!(
AppKey::from_hex(&"4 ".repeat(KEY_BYTES)).unwrap_err(),
AppKeyError::NotHexadecimal
);
assert_eq!(
AppKey::from_hex(&"é".repeat(KEY_BYTES)).unwrap_err(),
AppKeyError::NotHexadecimal
);
}
#[cfg(all(feature = "crypt", feature = "signed-urls"))]
#[test]
fn the_shipped_labels_derive_different_subkeys() {
use super::{ENCRYPTER_LABEL, URL_SIGNER_LABEL};
let key = key(0x55);
assert_ne!(ENCRYPTER_LABEL, URL_SIGNER_LABEL);
assert_ne!(
key.subkey(ENCRYPTER_LABEL).expose_secret(),
key.subkey(URL_SIGNER_LABEL).expose_secret()
);
}
#[test]
fn two_labels_give_two_unrelated_subkeys() {
let key = key(0x11);
let encrypter = key.subkey(LABEL);
let other = key.subkey(b"some-other-consumer");
assert_ne!(encrypter.expose_secret(), other.expose_secret());
assert_eq!(encrypter.expose_secret().len(), 32);
assert_eq!(other.expose_secret().len(), 32);
}
#[test]
fn a_subkey_is_not_the_master_key() {
let key = key(0x22);
assert_ne!(key.subkey(LABEL).expose_secret(), &[0x22u8; 32]);
}
#[test]
fn two_master_keys_give_two_subkeys() {
assert_ne!(
key(0x01).subkey(LABEL).expose_secret(),
key(0x02).subkey(LABEL).expose_secret()
);
}
#[test]
fn derivation_is_deterministic() {
assert_eq!(
key(0x33).subkey(LABEL).expose_secret(),
key(0x33).subkey(LABEL).expose_secret()
);
}
#[test]
fn labels_that_concatenate_alike_do_not_collide() {
let key = key(0x44);
assert_ne!(
key.subkey(b"ab").expose_secret(),
key.subkey(b"abc").expose_secret()
);
}
#[test]
fn debug_never_shows_the_key() {
let rendered = format!("{:?}", key(0x55));
assert_eq!(rendered, "AppKey(<redacted 64-byte key>)");
assert!(!rendered.contains("55"));
}
}