use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
use rainier_support::{Error, Result};
use sha2::{Digest, Sha256};
pub const KEY_LEN: usize = 32;
#[derive(Clone)]
pub struct Key {
id: String,
bytes: [u8; KEY_LEN],
}
impl Key {
pub fn from_bytes(bytes: [u8; KEY_LEN]) -> Self {
Self { id: fingerprint(&bytes), bytes }
}
pub fn from_base64(encoded: &str) -> Result<Self> {
let trimmed = encoded.trim();
let payload = trimmed.strip_prefix("base64:").unwrap_or(trimmed);
let decoded = BASE64
.decode(payload)
.map_err(|_| Error::internal("the application key is not valid base64"))?;
let bytes: [u8; KEY_LEN] = decoded.as_slice().try_into().map_err(|_| {
Error::internal(format!(
"the application key must be {KEY_LEN} bytes, but decoded to {}",
decoded.len()
))
})?;
Ok(Self::from_bytes(bytes))
}
pub fn generate() -> Self {
use rand::RngCore;
let mut bytes = [0u8; KEY_LEN];
rand::rngs::OsRng.fill_bytes(&mut bytes);
Self::from_bytes(bytes)
}
pub fn id(&self) -> &str {
&self.id
}
pub fn bytes(&self) -> &[u8; KEY_LEN] {
&self.bytes
}
pub fn to_base64(&self) -> String {
format!("base64:{}", BASE64.encode(self.bytes))
}
}
impl std::fmt::Debug for Key {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Key").field("id", &self.id).field("bytes", &"<redacted>").finish()
}
}
fn fingerprint(bytes: &[u8; KEY_LEN]) -> String {
let mut hasher = Sha256::new();
hasher.update(b"rainier-key-id\0");
hasher.update(bytes);
let digest = hasher.finalize();
digest.iter().take(4).map(|byte| format!("{byte:02x}")).collect()
}
#[derive(Clone, Debug)]
pub struct KeyRing {
keys: Vec<Key>,
}
impl KeyRing {
pub fn new(current: Key) -> Self {
Self { keys: vec![current] }
}
pub fn with_previous(mut self, key: Key) -> Self {
self.keys.push(key);
self
}
pub fn from_base64(current: &str, previous: &[String]) -> Result<Self> {
let mut ring = Self::new(Key::from_base64(current)?);
for encoded in previous {
if encoded.trim().is_empty() {
continue;
}
ring = ring.with_previous(Key::from_base64(encoded)?);
}
Ok(ring)
}
pub fn current(&self) -> &Key {
&self.keys[0]
}
pub fn find(&self, id: &str) -> Option<&Key> {
self.keys.iter().find(|key| key.id() == id)
}
pub fn len(&self) -> usize {
self.keys.len()
}
pub fn is_empty(&self) -> bool {
false
}
pub fn all(&self) -> impl Iterator<Item = &Key> {
self.keys.iter()
}
pub fn ids(&self) -> Vec<&str> {
self.keys.iter().map(Key::id).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_generated_key_round_trips_through_base64() {
let key = Key::generate();
let restored = Key::from_base64(&key.to_base64()).unwrap();
assert_eq!(restored.bytes(), key.bytes());
assert_eq!(restored.id(), key.id());
}
#[test]
fn the_base64_prefix_is_accepted_and_optional() {
let key = Key::generate();
let raw = BASE64.encode(key.bytes());
assert_eq!(Key::from_base64(&raw).unwrap().bytes(), key.bytes());
assert_eq!(Key::from_base64(&format!("base64:{raw}")).unwrap().bytes(), key.bytes());
}
#[test]
fn a_key_of_the_wrong_length_is_rejected_with_its_length() {
let short = BASE64.encode([0u8; 16]);
let err = Key::from_base64(&short).unwrap_err();
assert!(err.message().contains("32"), "{}", err.message());
assert!(err.message().contains("16"), "{}", err.message());
}
#[test]
fn rubbish_is_rejected_as_base64() {
assert!(Key::from_base64("not base64 at all!!").is_err());
}
#[test]
fn the_id_is_derived_from_the_key_and_is_stable() {
let key = Key::from_bytes([7u8; KEY_LEN]);
let same = Key::from_bytes([7u8; KEY_LEN]);
let other = Key::from_bytes([8u8; KEY_LEN]);
assert_eq!(key.id(), same.id());
assert_ne!(key.id(), other.id());
assert_eq!(key.id().len(), 8);
}
#[test]
fn debug_does_not_disclose_the_key() {
let key = Key::generate();
let rendered = format!("{key:?}");
assert!(rendered.contains("redacted"));
assert!(!rendered.contains(&BASE64.encode(key.bytes())), "{rendered}");
assert!(rendered.contains(key.id()), "{rendered}");
}
#[test]
fn a_ring_finds_current_and_retired_keys_by_id() {
let current = Key::generate();
let old = Key::generate();
let ring = KeyRing::new(current.clone()).with_previous(old.clone());
assert_eq!(ring.current().id(), current.id());
assert_eq!(ring.find(old.id()).unwrap().bytes(), old.bytes());
assert!(ring.find("00000000").is_none());
assert_eq!(ring.len(), 2);
}
#[test]
fn empty_previous_entries_are_skipped() {
let key = Key::generate().to_base64();
let ring = KeyRing::from_base64(&key, &[String::new(), " ".to_string()]).unwrap();
assert_eq!(ring.len(), 1, "blank entries in a comma-separated list are not keys");
}
}