use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use chrono::{DateTime, Utc};
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use rand::TryRngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tokio::fs;
use tokio::sync::Mutex;
use crate::SecretsError;
const SIGNING_KEY_SEED_LEN: usize = 32;
pub(crate) const KEYSTORE_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct KeyEntry {
pub(crate) id: String,
pub(crate) seed_b64: String,
pub(crate) created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct KeyStoreFile {
pub(crate) version: u32,
pub(crate) keys: Vec<KeyEntry>,
pub(crate) active: String,
#[serde(default)]
pub(crate) retired_grace_until: HashMap<String, DateTime<Utc>>,
}
pub struct ClusterSigner {
signing: SigningKey,
public: VerifyingKey,
}
impl std::fmt::Debug for ClusterSigner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClusterSigner")
.field("key_id", &self.key_id())
.field("signing", &"<redacted>")
.finish()
}
}
impl ClusterSigner {
#[must_use]
pub fn generate() -> Self {
let mut seed = [0u8; SIGNING_KEY_SEED_LEN];
rand::rngs::OsRng
.try_fill_bytes(&mut seed)
.expect("OS CSPRNG must be available to generate a cluster signer key");
let signing = SigningKey::from_bytes(&seed);
let public = signing.verifying_key();
Self { signing, public }
}
pub async fn load_or_generate(path: &Path) -> Result<Self, SecretsError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await.map_err(|e| {
SecretsError::Storage(format!(
"Failed to create cluster signer directory {}: {e}",
parent.display()
))
})?;
}
if !fs::try_exists(path).await.map_err(|e| {
SecretsError::Storage(format!(
"Failed to stat cluster signer key file {}: {e}",
path.display()
))
})? {
let signer = Self::generate();
let entry = KeyEntry {
id: signer.key_id(),
seed_b64: URL_SAFE_NO_PAD.encode(signer.signing.to_bytes()),
created_at: Utc::now(),
};
let store = KeyStoreFile {
version: KEYSTORE_VERSION,
active: entry.id.clone(),
keys: vec![entry],
retired_grace_until: HashMap::new(),
};
Self::write_keystore(path, &store).await?;
return Ok(signer);
}
let store = Self::read_keystore(path).await?;
Self::from_keystore(&store, path)
}
fn from_keystore(store: &KeyStoreFile, path: &Path) -> Result<Self, SecretsError> {
let active = store
.keys
.iter()
.find(|k| k.id == store.active)
.ok_or_else(|| {
SecretsError::Storage(format!(
"cluster signer keystore {} declares active kid {:?} but no matching key entry exists",
path.display(),
store.active
))
})?;
let seed_bytes = URL_SAFE_NO_PAD.decode(&active.seed_b64).map_err(|e| {
SecretsError::Storage(format!(
"cluster signer keystore {} has invalid base64 seed for kid {:?}: {e}",
path.display(),
active.id
))
})?;
if seed_bytes.len() != SIGNING_KEY_SEED_LEN {
return Err(SecretsError::Storage(format!(
"cluster signer keystore {} has wrong seed length for kid {:?}: expected {}, got {}",
path.display(),
active.id,
SIGNING_KEY_SEED_LEN,
seed_bytes.len()
)));
}
let mut seed = [0u8; SIGNING_KEY_SEED_LEN];
seed.copy_from_slice(&seed_bytes);
let signing = SigningKey::from_bytes(&seed);
let public = signing.verifying_key();
Ok(Self { signing, public })
}
pub(crate) async fn read_keystore(path: &Path) -> Result<KeyStoreFile, SecretsError> {
let buf = fs::read(path).await.map_err(|e| {
SecretsError::Storage(format!(
"Failed to read cluster signer key file {}: {e}",
path.display()
))
})?;
match serde_json::from_slice::<KeyStoreFile>(&buf) {
Ok(store) => Ok(store),
Err(json_err) => {
if buf.len() == SIGNING_KEY_SEED_LEN {
let mut seed = [0u8; SIGNING_KEY_SEED_LEN];
seed.copy_from_slice(&buf);
let signing = SigningKey::from_bytes(&seed);
let public = signing.verifying_key();
let digest = Sha256::digest(public.as_bytes());
let kid = hex_short(&digest);
let entry = KeyEntry {
id: kid.clone(),
seed_b64: URL_SAFE_NO_PAD.encode(seed),
created_at: Utc::now(),
};
let store = KeyStoreFile {
version: KEYSTORE_VERSION,
active: kid,
keys: vec![entry],
retired_grace_until: HashMap::new(),
};
Self::write_keystore(path, &store).await?;
Ok(store)
} else {
Err(SecretsError::Storage(format!(
"cluster signer key file {} has unexpected format: not valid keystore JSON ({json_err}) and not a {SIGNING_KEY_SEED_LEN}-byte legacy seed (got {} bytes)",
path.display(),
buf.len()
)))
}
}
}
}
pub(crate) async fn write_keystore(
path: &Path,
store: &KeyStoreFile,
) -> Result<(), SecretsError> {
let json = serde_json::to_vec_pretty(store).map_err(|e| {
SecretsError::Storage(format!(
"Failed to serialize cluster signer keystore for {}: {e}",
path.display()
))
})?;
let tmp = tmp_path_for(path);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
use tokio::fs::OpenOptions;
use tokio::io::AsyncWriteExt as _;
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp)
.await
.map_err(|e| {
SecretsError::Storage(format!(
"Failed to create cluster signer keystore temp file {}: {e}",
tmp.display()
))
})?;
file.write_all(&json).await.map_err(|e| {
SecretsError::Storage(format!(
"Failed to write cluster signer keystore temp file {}: {e}",
tmp.display()
))
})?;
file.flush().await.map_err(|e| {
SecretsError::Storage(format!(
"Failed to flush cluster signer keystore temp file {}: {e}",
tmp.display()
))
})?;
let permissions = std::fs::Permissions::from_mode(0o600);
fs::set_permissions(&tmp, permissions).await.map_err(|e| {
SecretsError::Storage(format!(
"Failed to set permissions on cluster signer keystore temp file {}: {e}",
tmp.display()
))
})?;
}
#[cfg(not(unix))]
{
fs::write(&tmp, &json).await.map_err(|e| {
SecretsError::Storage(format!(
"Failed to write cluster signer keystore temp file {}: {e}",
tmp.display()
))
})?;
}
fs::rename(&tmp, path).await.map_err(|e| {
SecretsError::Storage(format!(
"Failed to rename cluster signer keystore {} -> {}: {e}",
tmp.display(),
path.display()
))
})?;
Ok(())
}
#[must_use]
pub fn verifying_key(&self) -> VerifyingKey {
self.public
}
#[must_use]
pub fn public_key_b64(&self) -> String {
URL_SAFE_NO_PAD.encode(self.public.as_bytes())
}
#[must_use]
pub fn key_id(&self) -> String {
let digest = Sha256::digest(self.public.as_bytes());
hex_short(&digest)
}
#[must_use]
pub fn sign(&self, msg: &[u8]) -> [u8; 64] {
let sig: Signature = self.signing.sign(msg);
sig.to_bytes()
}
}
fn hex_short(digest: &[u8]) -> String {
hex::encode(&digest[..4])
}
fn tmp_path_for(path: &Path) -> PathBuf {
let mut os = path.as_os_str().to_owned();
os.push(".tmp");
PathBuf::from(os)
}
fn keystore_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PubkeyStatus {
Active,
Grace,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PubkeyInfo {
pub kid: String,
pub public_key_b64: String,
pub status: PubkeyStatus,
pub valid_until: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeystoreRotationResult {
pub new_active_kid: String,
pub new_active_public_key_b64: String,
pub previous_kid: String,
pub previous_grace_until: DateTime<Utc>,
}
pub async fn rotate_keystore(
path: &Path,
grace: std::time::Duration,
) -> Result<KeystoreRotationResult, SecretsError> {
let _guard = keystore_lock().lock().await;
let mut store = ClusterSigner::read_keystore(path).await?;
let old_active = store.active.clone();
let mut seed = [0u8; SIGNING_KEY_SEED_LEN];
rand::rngs::OsRng
.try_fill_bytes(&mut seed)
.map_err(|e| SecretsError::Storage(format!("OS CSPRNG failed during rotation: {e}")))?;
let signing = SigningKey::from_bytes(&seed);
let public = signing.verifying_key();
let new_kid = {
let digest = Sha256::digest(public.as_bytes());
hex_short(&digest)
};
let new_pub_b64 = URL_SAFE_NO_PAD.encode(public.as_bytes());
if store.keys.iter().any(|k| k.id == new_kid) {
return Err(SecretsError::Storage(format!(
"kid collision; retry rotation (new_kid={new_kid} already in keystore)"
)));
}
let now = Utc::now();
let grace_chrono = chrono::Duration::from_std(grace).unwrap_or(chrono::Duration::MAX);
let previous_grace_until = now
.checked_add_signed(grace_chrono)
.unwrap_or(DateTime::<Utc>::MAX_UTC);
store.keys.push(KeyEntry {
id: new_kid.clone(),
seed_b64: URL_SAFE_NO_PAD.encode(seed),
created_at: now,
});
store
.retired_grace_until
.insert(old_active.clone(), previous_grace_until);
store.active.clone_from(&new_kid);
ClusterSigner::write_keystore(path, &store).await?;
Ok(KeystoreRotationResult {
new_active_kid: new_kid,
new_active_public_key_b64: new_pub_b64,
previous_kid: old_active,
previous_grace_until,
})
}
pub async fn load_signer_for_kid(
path: &Path,
kid: &str,
) -> Result<Option<ClusterSigner>, SecretsError> {
let store = ClusterSigner::read_keystore(path).await?;
let Some(entry) = store.keys.iter().find(|k| k.id == kid) else {
return Ok(None);
};
let valid = if store.active == kid {
true
} else if let Some(expires_at) = store.retired_grace_until.get(kid) {
Utc::now() < *expires_at
} else {
false
};
if !valid {
return Ok(None);
}
let seed_bytes = URL_SAFE_NO_PAD.decode(&entry.seed_b64).map_err(|e| {
SecretsError::Storage(format!(
"cluster signer keystore {} has invalid base64 seed for kid {:?}: {e}",
path.display(),
entry.id
))
})?;
if seed_bytes.len() != SIGNING_KEY_SEED_LEN {
return Err(SecretsError::Storage(format!(
"cluster signer keystore {} has wrong seed length for kid {:?}: expected {}, got {}",
path.display(),
entry.id,
SIGNING_KEY_SEED_LEN,
seed_bytes.len()
)));
}
let mut seed = [0u8; SIGNING_KEY_SEED_LEN];
seed.copy_from_slice(&seed_bytes);
let signing = SigningKey::from_bytes(&seed);
let public = signing.verifying_key();
Ok(Some(ClusterSigner { signing, public }))
}
pub async fn list_valid_pubkeys(path: &Path) -> Result<Vec<PubkeyInfo>, SecretsError> {
let store = ClusterSigner::read_keystore(path).await?;
let now = Utc::now();
let mut active_info: Option<PubkeyInfo> = None;
let mut grace_infos: Vec<PubkeyInfo> = Vec::new();
for entry in &store.keys {
let seed_bytes = URL_SAFE_NO_PAD.decode(&entry.seed_b64).map_err(|e| {
SecretsError::Storage(format!(
"cluster signer keystore {} has invalid base64 seed for kid {:?}: {e}",
path.display(),
entry.id
))
})?;
if seed_bytes.len() != SIGNING_KEY_SEED_LEN {
return Err(SecretsError::Storage(format!(
"cluster signer keystore {} has wrong seed length for kid {:?}: expected {}, got {}",
path.display(),
entry.id,
SIGNING_KEY_SEED_LEN,
seed_bytes.len()
)));
}
let mut seed = [0u8; SIGNING_KEY_SEED_LEN];
seed.copy_from_slice(&seed_bytes);
let public = SigningKey::from_bytes(&seed).verifying_key();
let public_key_b64 = URL_SAFE_NO_PAD.encode(public.as_bytes());
if entry.id == store.active {
active_info = Some(PubkeyInfo {
kid: entry.id.clone(),
public_key_b64,
status: PubkeyStatus::Active,
valid_until: None,
created_at: entry.created_at,
});
} else if let Some(expires_at) = store.retired_grace_until.get(&entry.id) {
if now < *expires_at {
grace_infos.push(PubkeyInfo {
kid: entry.id.clone(),
public_key_b64,
status: PubkeyStatus::Grace,
valid_until: Some(*expires_at),
created_at: entry.created_at,
});
}
}
}
grace_infos.sort_by(|a, b| b.valid_until.cmp(&a.valid_until));
let mut out = Vec::with_capacity(1 + grace_infos.len());
if let Some(active) = active_info {
out.push(active);
}
out.extend(grace_infos);
Ok(out)
}
pub async fn prune_expired_grace(path: &Path) -> Result<usize, SecretsError> {
let _guard = keystore_lock().lock().await;
let mut store = ClusterSigner::read_keystore(path).await?;
let now = Utc::now();
let expired: Vec<String> = store
.retired_grace_until
.iter()
.filter_map(|(kid, expires)| {
if *expires <= now {
Some(kid.clone())
} else {
None
}
})
.collect();
if expired.is_empty() {
return Ok(0);
}
for kid in &expired {
if *kid == store.active {
continue;
}
store.retired_grace_until.remove(kid);
store.keys.retain(|k| &k.id != kid);
}
let pruned = expired.iter().filter(|k| **k != store.active).count();
if pruned == 0 {
return Ok(0);
}
ClusterSigner::write_keystore(path, &store).await?;
Ok(pruned)
}
pub struct ClusterCa {
signing: ed25519_dalek::SigningKey,
public: ed25519_dalek::VerifyingKey,
}
impl std::fmt::Debug for ClusterCa {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClusterCa")
.field("ca_kid", &self.ca_kid())
.finish_non_exhaustive()
}
}
impl ClusterCa {
#[must_use]
pub fn generate() -> Self {
use rand::TryRngCore;
let mut seed = [0u8; 32];
rand::rngs::OsRng
.try_fill_bytes(&mut seed)
.expect("OS CSPRNG must be available to generate a cluster CA key");
let signing = ed25519_dalek::SigningKey::from_bytes(&seed);
let public = signing.verifying_key();
Self { signing, public }
}
pub async fn load_or_generate(path: &std::path::Path) -> Result<Self, SecretsError> {
if tokio::fs::try_exists(path)
.await
.map_err(|e| SecretsError::Storage(format!("checking {}: {e}", path.display())))?
{
let bytes = tokio::fs::read(path)
.await
.map_err(|e| SecretsError::Storage(format!("reading {}: {e}", path.display())))?;
if bytes.len() != 32 {
return Err(SecretsError::Storage(format!(
"cluster_ca.key at {} has wrong length: expected 32 bytes, got {}",
path.display(),
bytes.len()
)));
}
let mut seed = [0u8; 32];
seed.copy_from_slice(&bytes);
let signing = ed25519_dalek::SigningKey::from_bytes(&seed);
let public = signing.verifying_key();
return Ok(Self { signing, public });
}
let ca = Self::generate();
let seed = ca.signing.to_bytes();
let tmp_path = path.with_extension("ca.tmp");
tokio::fs::write(&tmp_path, &seed[..])
.await
.map_err(|e| SecretsError::Storage(format!("writing tmp ca file: {e}")))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
tokio::fs::set_permissions(&tmp_path, perms)
.await
.map_err(|e| SecretsError::Storage(format!("chmod 0600 ca tmp: {e}")))?;
}
tokio::fs::rename(&tmp_path, path)
.await
.map_err(|e| SecretsError::Storage(format!("rename ca tmp to final: {e}")))?;
Ok(ca)
}
#[must_use]
pub fn ca_public_key_b64(&self) -> String {
URL_SAFE_NO_PAD.encode(self.public.as_bytes())
}
#[must_use]
pub fn ca_kid(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(self.public.as_bytes());
let digest = hasher.finalize();
hex_short(&digest)
}
#[must_use]
pub fn verifying_key(&self) -> ed25519_dalek::VerifyingKey {
self.public
}
pub fn issue_ca_cert(
&self,
active_kid: String,
active_pubkey_b64: String,
cluster_domain: String,
grace: std::time::Duration,
) -> Result<zlayer_types::api::cluster::CaCert, SecretsError> {
use ed25519_dalek::Signer;
let now = chrono::Utc::now();
let issued_at = now.to_rfc3339();
let expires_at = (now
+ chrono::Duration::from_std(grace)
.map_err(|e| SecretsError::Provider(format!("grace out of range: {e}")))?)
.to_rfc3339();
let mut cert = zlayer_types::api::cluster::CaCert {
v: zlayer_types::api::cluster::CA_CERT_FORMAT_VERSION,
active_kid,
active_pubkey_b64,
issued_at,
expires_at,
cluster_domain,
sig_by_ca: String::new(),
};
let body_bytes = serde_json::to_vec(&cert).map_err(|e| {
SecretsError::Provider(format!("serializing CaCert body for signing: {e}"))
})?;
let sig = self.signing.sign(&body_bytes);
cert.sig_by_ca = URL_SAFE_NO_PAD.encode(sig.to_bytes());
Ok(cert)
}
pub fn verify_ca_cert(
ca_pubkey_b64: &str,
cert: &zlayer_types::api::cluster::CaCert,
) -> Result<(), SecretsError> {
let ca_pubkey_bytes = URL_SAFE_NO_PAD
.decode(ca_pubkey_b64.as_bytes())
.map_err(|e| SecretsError::Provider(format!("CA pubkey base64 decode: {e}")))?;
let ca_pubkey_arr: [u8; 32] = ca_pubkey_bytes.as_slice().try_into().map_err(|_| {
SecretsError::Provider(format!("CA pubkey wrong length: {}", ca_pubkey_bytes.len()))
})?;
let ca_pubkey = ed25519_dalek::VerifyingKey::from_bytes(&ca_pubkey_arr)
.map_err(|e| SecretsError::Provider(format!("invalid CA pubkey: {e}")))?;
let sig_bytes = URL_SAFE_NO_PAD
.decode(cert.sig_by_ca.as_bytes())
.map_err(|e| SecretsError::Provider(format!("sig_by_ca base64 decode: {e}")))?;
let sig_arr: [u8; 64] = sig_bytes.as_slice().try_into().map_err(|_| {
SecretsError::Provider(format!("sig_by_ca wrong length: {}", sig_bytes.len()))
})?;
let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
let mut body = cert.clone();
body.sig_by_ca = String::new();
let body_bytes = serde_json::to_vec(&body).map_err(|e| {
SecretsError::Provider(format!("recomputing CaCert canonical body: {e}"))
})?;
ca_pubkey.verify_strict(&body_bytes, &sig).map_err(|e| {
SecretsError::Provider(format!("CA signature verification failed: {e}"))
})?;
let exp = chrono::DateTime::parse_from_rfc3339(&cert.expires_at)
.map_err(|e| SecretsError::Provider(format!("CaCert expires_at parse: {e}")))?
.with_timezone(&chrono::Utc);
if chrono::Utc::now() >= exp {
return Err(SecretsError::Provider(format!(
"CaCert expired at {}",
cert.expires_at
)));
}
Ok(())
}
}
#[async_trait::async_trait]
pub trait SigningBackend: Send + Sync + std::fmt::Debug {
fn name(&self) -> &'static str;
fn is_hardware_backed(&self) -> bool;
async fn active_key_id(&self) -> Result<String, SecretsError>;
async fn public_key_b64(&self, kid: &str) -> Result<Option<String>, SecretsError>;
async fn list_valid_pubkeys(&self) -> Result<Vec<PubkeyInfo>, SecretsError>;
async fn sign(&self, kid: &str, msg: &[u8]) -> Result<[u8; 64], SecretsError>;
async fn rotate(
&self,
grace: std::time::Duration,
) -> Result<KeystoreRotationResult, SecretsError>;
async fn prune_expired_grace(&self) -> Result<usize, SecretsError>;
}
#[derive(Debug, Clone)]
pub struct FileBackend {
path: std::path::PathBuf,
}
impl FileBackend {
#[must_use]
pub fn new(path: std::path::PathBuf) -> Self {
Self { path }
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
}
#[async_trait::async_trait]
impl SigningBackend for FileBackend {
fn name(&self) -> &'static str {
"file"
}
fn is_hardware_backed(&self) -> bool {
false
}
async fn active_key_id(&self) -> Result<String, SecretsError> {
let signer = ClusterSigner::load_or_generate(&self.path).await?;
Ok(signer.key_id())
}
async fn public_key_b64(&self, kid: &str) -> Result<Option<String>, SecretsError> {
Ok(load_signer_for_kid(&self.path, kid)
.await?
.map(|s| s.public_key_b64()))
}
async fn list_valid_pubkeys(&self) -> Result<Vec<PubkeyInfo>, SecretsError> {
list_valid_pubkeys(&self.path).await
}
async fn sign(&self, kid: &str, msg: &[u8]) -> Result<[u8; 64], SecretsError> {
let signer = load_signer_for_kid(&self.path, kid).await?.ok_or_else(|| {
SecretsError::Provider(format!(
"kid {kid} not in keystore (unknown or grace expired)"
))
})?;
Ok(signer.sign(msg))
}
async fn rotate(
&self,
grace: std::time::Duration,
) -> Result<KeystoreRotationResult, SecretsError> {
rotate_keystore(&self.path, grace).await
}
async fn prune_expired_grace(&self) -> Result<usize, SecretsError> {
prune_expired_grace(&self.path).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signature, Verifier};
use tempfile::tempdir;
#[test]
fn generate_produces_distinct_keys() {
let a = ClusterSigner::generate();
let b = ClusterSigner::generate();
assert_ne!(
a.key_id(),
b.key_id(),
"two fresh generate() calls produced the same key_id"
);
assert_ne!(a.verifying_key().as_bytes(), b.verifying_key().as_bytes());
}
#[tokio::test]
async fn round_trip_through_disk() {
let dir = tempdir().unwrap();
let path = dir.path().join("cluster_signer.key");
let first = ClusterSigner::load_or_generate(&path).await.unwrap();
let first_id = first.key_id();
assert!(path.exists(), "key file was not persisted");
let on_disk = std::fs::read_to_string(&path).unwrap();
assert!(
on_disk.starts_with('{'),
"expected JSON keystore, got: {on_disk:?}"
);
let second = ClusterSigner::load_or_generate(&path).await.unwrap();
assert_eq!(
first_id,
second.key_id(),
"second load_or_generate regenerated instead of loading"
);
assert_eq!(
first.verifying_key().as_bytes(),
second.verifying_key().as_bytes()
);
}
#[test]
fn sign_then_verify() {
let signer = ClusterSigner::generate();
let msg = b"join-token-payload-v1";
let sig_bytes = signer.sign(msg);
let sig = Signature::from_bytes(&sig_bytes);
signer
.verifying_key()
.verify(msg, &sig)
.expect("valid signature should verify");
let mut tampered = msg.to_vec();
tampered[0] ^= 0x01;
assert!(
signer.verifying_key().verify(&tampered, &sig).is_err(),
"tampered message verified against original signature"
);
let mut bad_sig_bytes = sig_bytes;
bad_sig_bytes[0] ^= 0x01;
let bad_sig = Signature::from_bytes(&bad_sig_bytes);
assert!(
signer.verifying_key().verify(msg, &bad_sig).is_err(),
"tampered signature verified against original message"
);
}
#[test]
fn key_id_is_8_hex_chars() {
let signer = ClusterSigner::generate();
let id = signer.key_id();
assert_eq!(id.len(), 8, "key_id should be 8 hex chars, got {id:?}");
assert!(
id.chars().all(|c| c.is_ascii_hexdigit()),
"key_id should be hex, got {id:?}"
);
}
#[test]
fn public_key_b64_round_trips() {
let signer = ClusterSigner::generate();
let b64 = signer.public_key_b64();
assert_eq!(b64.len(), 43);
let decoded = URL_SAFE_NO_PAD.decode(&b64).unwrap();
assert_eq!(decoded, signer.verifying_key().as_bytes());
}
#[tokio::test]
async fn load_or_generate_rejects_garbage_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("cluster_signer.key");
std::fs::write(&path, [0u8; 16]).unwrap();
let err = ClusterSigner::load_or_generate(&path)
.await
.expect_err("should reject 16-byte garbage file");
match err {
SecretsError::Storage(msg) => {
assert!(
msg.contains("unexpected format"),
"expected 'unexpected format' error, got: {msg}"
);
}
other => panic!("expected SecretsError::Storage, got {other:?}"),
}
}
#[cfg(unix)]
#[tokio::test]
async fn persisted_file_is_mode_0600_on_unix() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let path = dir.path().join("cluster_signer.key");
let _ = ClusterSigner::load_or_generate(&path).await.unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "expected mode 0600, got {mode:o}");
}
#[tokio::test]
async fn migration_from_raw_seed_file_works_once() {
let dir = tempdir().unwrap();
let path = dir.path().join("cluster_signing.key");
let mut legacy_seed = [0u8; 32];
rand::rngs::OsRng.try_fill_bytes(&mut legacy_seed).unwrap();
std::fs::write(&path, legacy_seed).unwrap();
let signer = ClusterSigner::load_or_generate(&path).await.unwrap();
let migrated_kid = signer.key_id();
let content = std::fs::read_to_string(&path).unwrap();
assert!(
content.starts_with('{'),
"expected JSON keystore after migration, got: {content:?}"
);
assert!(content.contains("\"version\":"));
assert!(content.contains("\"active\":"));
let again = ClusterSigner::load_or_generate(&path).await.unwrap();
assert_eq!(again.key_id(), migrated_kid);
assert_eq!(
signer.verifying_key().as_bytes(),
again.verifying_key().as_bytes()
);
}
#[tokio::test]
async fn fresh_load_or_generate_produces_json_keystore() {
let dir = tempdir().unwrap();
let path = dir.path().join("cluster_signing.key");
let _ = ClusterSigner::load_or_generate(&path).await.unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("\"version\":"));
assert!(content.contains("\"active\":"));
assert!(content.contains("\"keys\":"));
}
#[tokio::test]
async fn load_or_generate_idempotent_on_keystore() {
let dir = tempdir().unwrap();
let path = dir.path().join("cluster_signing.key");
let first = ClusterSigner::load_or_generate(&path).await.unwrap();
let json1 = std::fs::read_to_string(&path).unwrap();
let second = ClusterSigner::load_or_generate(&path).await.unwrap();
let json2 = std::fs::read_to_string(&path).unwrap();
assert_eq!(first.key_id(), second.key_id());
assert_eq!(
json1, json2,
"keystore should not be rewritten on a no-op load"
);
}
#[cfg(unix)]
#[tokio::test]
async fn keystore_file_is_mode_0600_on_unix() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let path = dir.path().join("cluster_signing.key");
let _ = ClusterSigner::load_or_generate(&path).await.unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
async fn read_store(path: &Path) -> KeyStoreFile {
ClusterSigner::read_keystore(path).await.unwrap()
}
#[tokio::test]
async fn rotation_flips_active_and_old_keeps_grace() {
let dir = tempdir().unwrap();
let path = dir.path().join("cluster_signing.key");
let original = ClusterSigner::load_or_generate(&path).await.unwrap();
let original_kid = original.key_id();
let result = rotate_keystore(&path, std::time::Duration::from_secs(3600))
.await
.unwrap();
assert_ne!(result.new_active_kid, result.previous_kid);
assert_eq!(result.previous_kid, original_kid);
let store = read_store(&path).await;
assert_eq!(store.active, result.new_active_kid);
assert_eq!(store.keys.len(), 2);
assert!(store.retired_grace_until.contains_key(&result.previous_kid));
assert!(!store
.retired_grace_until
.contains_key(&result.new_active_kid));
}
#[tokio::test]
async fn rotation_returns_correct_grace_expiry() {
let dir = tempdir().unwrap();
let path = dir.path().join("cluster_signing.key");
let _ = ClusterSigner::load_or_generate(&path).await.unwrap();
let before = Utc::now();
let result = rotate_keystore(&path, std::time::Duration::from_secs(7200))
.await
.unwrap();
let after = Utc::now();
let lower = before + chrono::Duration::seconds(7200);
let upper = after + chrono::Duration::seconds(7200);
assert!(
result.previous_grace_until >= lower && result.previous_grace_until <= upper,
"grace_until {:?} not within expected window [{:?}, {:?}]",
result.previous_grace_until,
lower,
upper,
);
let store = read_store(&path).await;
assert_eq!(
store.retired_grace_until.get(&result.previous_kid).copied(),
Some(result.previous_grace_until)
);
}
#[tokio::test]
async fn load_signer_for_kid_returns_active() {
let dir = tempdir().unwrap();
let path = dir.path().join("cluster_signing.key");
let active = ClusterSigner::load_or_generate(&path).await.unwrap();
let loaded = load_signer_for_kid(&path, &active.key_id())
.await
.unwrap()
.expect("active kid should load");
assert_eq!(loaded.key_id(), active.key_id());
assert_eq!(
loaded.verifying_key().as_bytes(),
active.verifying_key().as_bytes()
);
}
#[tokio::test]
async fn load_signer_for_kid_returns_grace() {
let dir = tempdir().unwrap();
let path = dir.path().join("cluster_signing.key");
let original = ClusterSigner::load_or_generate(&path).await.unwrap();
let original_kid = original.key_id();
let _result = rotate_keystore(&path, std::time::Duration::from_secs(3600))
.await
.unwrap();
let loaded = load_signer_for_kid(&path, &original_kid)
.await
.unwrap()
.expect("grace-period kid should still load");
assert_eq!(loaded.key_id(), original_kid);
assert_eq!(
loaded.verifying_key().as_bytes(),
original.verifying_key().as_bytes()
);
}
#[tokio::test]
async fn load_signer_for_kid_returns_none_for_unknown() {
let dir = tempdir().unwrap();
let path = dir.path().join("cluster_signing.key");
let _ = ClusterSigner::load_or_generate(&path).await.unwrap();
let loaded = load_signer_for_kid(&path, "deadbeef").await.unwrap();
assert!(loaded.is_none(), "unknown kid should return None");
}
#[tokio::test]
async fn load_signer_for_kid_returns_none_for_expired_grace() {
let dir = tempdir().unwrap();
let path = dir.path().join("cluster_signing.key");
let _ = ClusterSigner::load_or_generate(&path).await.unwrap();
let result = rotate_keystore(&path, std::time::Duration::from_secs(3600))
.await
.unwrap();
let mut store = read_store(&path).await;
let past = Utc::now() - chrono::Duration::seconds(60);
store
.retired_grace_until
.insert(result.previous_kid.clone(), past);
ClusterSigner::write_keystore(&path, &store).await.unwrap();
let loaded = load_signer_for_kid(&path, &result.previous_kid)
.await
.unwrap();
assert!(
loaded.is_none(),
"kid with expired grace must not load via load_signer_for_kid"
);
}
#[tokio::test]
async fn list_valid_pubkeys_returns_active_first_then_grace() {
let dir = tempdir().unwrap();
let path = dir.path().join("cluster_signing.key");
let original = ClusterSigner::load_or_generate(&path).await.unwrap();
let original_kid = original.key_id();
let result = rotate_keystore(&path, std::time::Duration::from_secs(3600))
.await
.unwrap();
assert_eq!(result.previous_kid, original_kid);
let list = list_valid_pubkeys(&path).await.unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0].status, PubkeyStatus::Active);
assert_eq!(list[0].kid, result.new_active_kid);
assert!(list[0].valid_until.is_none());
assert_eq!(list[1].status, PubkeyStatus::Grace);
assert_eq!(list[1].kid, original_kid);
assert!(list[1].valid_until.is_some());
}
#[tokio::test]
async fn list_valid_pubkeys_omits_expired_grace() {
let dir = tempdir().unwrap();
let path = dir.path().join("cluster_signing.key");
let _ = ClusterSigner::load_or_generate(&path).await.unwrap();
let result = rotate_keystore(&path, std::time::Duration::from_secs(3600))
.await
.unwrap();
let mut store = read_store(&path).await;
store.retired_grace_until.insert(
result.previous_kid.clone(),
Utc::now() - chrono::Duration::seconds(1),
);
ClusterSigner::write_keystore(&path, &store).await.unwrap();
let list = list_valid_pubkeys(&path).await.unwrap();
assert_eq!(list.len(), 1, "expired-grace entry should be omitted");
assert_eq!(list[0].kid, result.new_active_kid);
assert_eq!(list[0].status, PubkeyStatus::Active);
}
#[tokio::test]
async fn prune_expired_grace_removes_expired_entries() {
let dir = tempdir().unwrap();
let path = dir.path().join("cluster_signing.key");
let _ = ClusterSigner::load_or_generate(&path).await.unwrap();
let result = rotate_keystore(&path, std::time::Duration::from_secs(3600))
.await
.unwrap();
let mut store = read_store(&path).await;
store.retired_grace_until.insert(
result.previous_kid.clone(),
Utc::now() - chrono::Duration::seconds(1),
);
ClusterSigner::write_keystore(&path, &store).await.unwrap();
let pruned = prune_expired_grace(&path).await.unwrap();
assert_eq!(pruned, 1);
let after = read_store(&path).await;
assert_eq!(after.keys.len(), 1);
assert_eq!(after.keys[0].id, result.new_active_kid);
assert!(after.retired_grace_until.is_empty());
}
#[tokio::test]
async fn prune_expired_grace_is_idempotent() {
let dir = tempdir().unwrap();
let path = dir.path().join("cluster_signing.key");
let _ = ClusterSigner::load_or_generate(&path).await.unwrap();
let first = prune_expired_grace(&path).await.unwrap();
assert_eq!(first, 0);
let bytes_after_first = std::fs::read(&path).unwrap();
let second = prune_expired_grace(&path).await.unwrap();
assert_eq!(second, 0);
let bytes_after_second = std::fs::read(&path).unwrap();
assert_eq!(
bytes_after_first, bytes_after_second,
"idempotent prune must not rewrite the keystore on a no-op"
);
let result = rotate_keystore(&path, std::time::Duration::from_secs(3600))
.await
.unwrap();
let mut store = read_store(&path).await;
store.retired_grace_until.insert(
result.previous_kid.clone(),
Utc::now() - chrono::Duration::seconds(1),
);
ClusterSigner::write_keystore(&path, &store).await.unwrap();
let count = prune_expired_grace(&path).await.unwrap();
assert_eq!(count, 1);
let count_again = prune_expired_grace(&path).await.unwrap();
assert_eq!(count_again, 0);
}
#[tokio::test]
async fn rotate_then_load_signer_for_old_kid_still_works_within_grace() {
use ed25519_dalek::Verifier;
let dir = tempdir().unwrap();
let path = dir.path().join("cluster_signing.key");
let original = ClusterSigner::load_or_generate(&path).await.unwrap();
let original_kid = original.key_id();
let msg = b"join-token-payload";
let sig_bytes = original.sign(msg);
let _result = rotate_keystore(&path, std::time::Duration::from_secs(3600))
.await
.unwrap();
let loaded = load_signer_for_kid(&path, &original_kid)
.await
.unwrap()
.expect("old kid should still load while in grace");
let sig = Signature::from_bytes(&sig_bytes);
loaded
.verifying_key()
.verify(msg, &sig)
.expect("signature from pre-rotation key must verify against in-grace key");
}
#[tokio::test]
async fn file_backend_round_trips_through_trait() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ks.json");
let backend: std::sync::Arc<dyn SigningBackend> =
std::sync::Arc::new(FileBackend::new(path.clone()));
let active = backend.active_key_id().await.unwrap();
assert_eq!(active.len(), 8, "kid is 8 hex chars");
let pubkey = backend.public_key_b64(&active).await.unwrap();
assert!(pubkey.is_some(), "active key must resolve via the trait");
let msg = b"hello signing backend";
let sig = backend.sign(&active, msg).await.unwrap();
let pubkey_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(pubkey.unwrap())
.unwrap();
let verifying =
ed25519_dalek::VerifyingKey::from_bytes(&pubkey_bytes.try_into().unwrap()).unwrap();
let signature = ed25519_dalek::Signature::from_bytes(&sig);
verifying
.verify_strict(msg, &signature)
.expect("file-backend signature must verify against its own pubkey");
}
#[tokio::test]
async fn file_backend_reports_software_only() {
let dir = tempfile::tempdir().unwrap();
let backend = FileBackend::new(dir.path().join("ks.json"));
assert_eq!(backend.name(), "file");
assert!(
!backend.is_hardware_backed(),
"file backend must NOT report hardware-backed"
);
}
#[tokio::test]
async fn file_backend_rotate_through_trait_produces_grace_entry() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ks.json");
let backend = FileBackend::new(path);
let original_kid = backend.active_key_id().await.unwrap();
let result = backend
.rotate(std::time::Duration::from_secs(3600))
.await
.unwrap();
assert_ne!(result.new_active_kid, original_kid);
assert_eq!(result.previous_kid, original_kid);
let infos = backend.list_valid_pubkeys().await.unwrap();
assert_eq!(infos.len(), 2, "active + 1 grace entry after rotation");
}
#[tokio::test]
async fn file_backend_unknown_kid_returns_none() {
let dir = tempfile::tempdir().unwrap();
let backend = FileBackend::new(dir.path().join("ks.json"));
let _ = backend.active_key_id().await.unwrap(); let unknown = backend.public_key_b64("deadbeef").await.unwrap();
assert!(unknown.is_none(), "unknown kid must resolve to None");
}
#[tokio::test]
async fn cluster_ca_load_or_generate_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cluster_ca.key");
let ca1 = ClusterCa::load_or_generate(&path).await.unwrap();
let kid1 = ca1.ca_kid();
let pubkey1 = ca1.ca_public_key_b64();
assert_eq!(kid1.len(), 8);
assert!(!pubkey1.is_empty());
let ca2 = ClusterCa::load_or_generate(&path).await.unwrap();
assert_eq!(ca2.ca_kid(), kid1);
assert_eq!(ca2.ca_public_key_b64(), pubkey1);
let bytes = tokio::fs::read(&path).await.unwrap();
assert_eq!(bytes.len(), 32);
}
#[tokio::test]
async fn cluster_ca_issues_and_verifies_ca_cert() {
use std::time::Duration;
let dir = tempfile::tempdir().unwrap();
let ca_path = dir.path().join("cluster_ca.key");
let ca = ClusterCa::load_or_generate(&ca_path).await.unwrap();
let active_kid = "deadbeef".to_string();
let active_pubkey_b64 = "Y29udGVudG9mYV9ub25fcGtfYi02NF9zaWduZWRfa2V5Xw".to_string();
let cluster_domain = "test-cluster".to_string();
let cert = ca
.issue_ca_cert(
active_kid.clone(),
active_pubkey_b64.clone(),
cluster_domain.clone(),
Duration::from_secs(3600),
)
.unwrap();
assert_eq!(cert.active_kid, active_kid);
assert_eq!(cert.cluster_domain, cluster_domain);
assert_eq!(cert.v, zlayer_types::api::cluster::CA_CERT_FORMAT_VERSION);
assert!(!cert.sig_by_ca.is_empty());
ClusterCa::verify_ca_cert(&ca.ca_public_key_b64(), &cert).unwrap();
}
#[tokio::test]
async fn cluster_ca_cert_verification_fails_under_wrong_pubkey() {
use std::time::Duration;
let dir = tempfile::tempdir().unwrap();
let ca = ClusterCa::load_or_generate(&dir.path().join("ca1.key"))
.await
.unwrap();
let other = ClusterCa::load_or_generate(&dir.path().join("ca2.key"))
.await
.unwrap();
let cert = ca
.issue_ca_cert(
"abcd1234".into(),
"ignored-for-this-test-xx".into(),
"test-cluster".into(),
Duration::from_secs(3600),
)
.unwrap();
let err = ClusterCa::verify_ca_cert(&other.ca_public_key_b64(), &cert).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("verification failed") || msg.contains("signature"),
"expected sig-verification error; got {msg}"
);
}
#[tokio::test]
async fn cluster_ca_cert_verification_fails_when_expired() {
use std::time::Duration;
let dir = tempfile::tempdir().unwrap();
let ca = ClusterCa::load_or_generate(&dir.path().join("ca.key"))
.await
.unwrap();
let cert = ca
.issue_ca_cert(
"abcd1234".into(),
"ignored-for-this-test-xx".into(),
"test".into(),
Duration::from_millis(1),
)
.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
let err = ClusterCa::verify_ca_cert(&ca.ca_public_key_b64(), &cert).unwrap_err();
assert!(
err.to_string().contains("expired"),
"expected expired-cert error; got {err}"
);
}
}