use std::collections::BTreeMap;
use std::time::Duration;
use ed25519_dalek::{Signature, Signer as _, SigningKey, Verifier as _, VerifyingKey};
pub const SIG: &str = "Kanade-Sig";
pub const SIG_KID: &str = "Kanade-Sig-Kid";
pub const SIG_ALG: &str = "Kanade-Sig-Alg";
pub const SIG_AT: &str = "Kanade-Sig-At";
pub const ALG_ED25519: &str = "ed25519";
pub fn signed_material(at_ms: i64, body: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(8 + body.len());
out.extend_from_slice(&at_ms.to_be_bytes());
out.extend_from_slice(body);
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeyPolicy {
pub label: String,
pub max_age: Option<Duration>,
pub audit_every_use: bool,
}
impl KeyPolicy {
pub fn backend(label: impl Into<String>) -> Self {
Self {
label: label.into(),
max_age: None,
audit_every_use: false,
}
}
pub fn break_glass(label: impl Into<String>, max_age: Duration) -> Self {
Self {
label: label.into(),
max_age: Some(max_age),
audit_every_use: true,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct KeyRing {
keys: BTreeMap<String, (VerifyingKey, KeyPolicy)>,
}
impl KeyRing {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, kid: impl Into<String>, key: VerifyingKey, policy: KeyPolicy) {
self.keys.insert(kid.into(), (key, policy));
}
pub fn is_empty(&self) -> bool {
self.keys.is_empty()
}
pub fn get(&self, kid: &str) -> Option<(&str, &VerifyingKey, &KeyPolicy)> {
self.keys
.get_key_value(kid)
.map(|(k, (key, policy))| (k.as_str(), key, policy))
}
pub fn kids(&self) -> impl Iterator<Item = &str> {
self.keys.keys().map(String::as_str)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VerifyError {
Unsigned,
UnknownKid { kid: String },
UnsupportedAlg { alg: String },
Malformed(String),
BadSignature { kid: String },
Stale {
kid: String,
age_ms: i64,
max_age_ms: u128,
},
}
impl std::fmt::Display for VerifyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VerifyError::Unsigned => write!(f, "no signature"),
VerifyError::UnknownKid { kid } => {
write!(
f,
"signed by unknown key id {kid} — is this agent's keyring current?"
)
}
VerifyError::UnsupportedAlg { alg } => {
write!(f, "unsupported signature algorithm {alg}")
}
VerifyError::Malformed(e) => write!(f, "malformed signature: {e}"),
VerifyError::BadSignature { kid } => {
write!(f, "signature by {kid} does not match these bytes")
}
VerifyError::Stale {
kid,
age_ms,
max_age_ms,
} => write!(
f,
"signature by {kid} is {age_ms}ms old, past its {max_age_ms}ms bound"
),
}
}
}
impl std::error::Error for VerifyError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Verified<'a> {
pub kid: &'a str,
pub policy: &'a KeyPolicy,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SigHeaders {
pub sig_b64: Option<String>,
pub kid: Option<String>,
pub alg: Option<String>,
pub at_ms: Option<String>,
}
impl SigHeaders {
pub fn is_absent(&self) -> bool {
self.sig_b64.is_none() && self.kid.is_none() && self.alg.is_none() && self.at_ms.is_none()
}
}
pub fn verify<'a>(
ring: &'a KeyRing,
body: &[u8],
headers: &SigHeaders,
now_ms: i64,
) -> Result<Verified<'a>, VerifyError> {
if headers.is_absent() {
return Err(VerifyError::Unsigned);
}
let kid = headers
.kid
.as_deref()
.ok_or_else(|| VerifyError::Malformed("signature without a key id".into()))?;
let sig_b64 = headers
.sig_b64
.as_deref()
.ok_or_else(|| VerifyError::Malformed("key id without a signature".into()))?;
let at_raw = headers
.at_ms
.as_deref()
.ok_or_else(|| VerifyError::Malformed("signature without a signing time".into()))?;
let at_ms: i64 = at_raw
.parse()
.map_err(|_| VerifyError::Malformed(format!("signing time {at_raw} is not a number")))?;
if let Some(alg) = headers.alg.as_deref()
&& alg != ALG_ED25519
{
return Err(VerifyError::UnsupportedAlg {
alg: alg.to_owned(),
});
}
let (ring_kid, key, policy) = ring.get(kid).ok_or_else(|| VerifyError::UnknownKid {
kid: kid.to_owned(),
})?;
let raw = base64_decode(sig_b64).map_err(VerifyError::Malformed)?;
let sig = Signature::from_slice(&raw).map_err(|e| VerifyError::Malformed(e.to_string()))?;
key.verify(&signed_material(at_ms, body), &sig)
.map_err(|_| VerifyError::BadSignature {
kid: kid.to_owned(),
})?;
if let Some(max_age) = policy.max_age {
let age_ms = now_ms - at_ms;
let max_age_ms = max_age.as_millis();
let age = age_ms as i128;
let bound = max_age_ms as i128;
if age > bound || age < -bound {
return Err(VerifyError::Stale {
kid: kid.to_owned(),
age_ms,
max_age_ms,
});
}
}
Ok(Verified {
kid: ring_kid,
policy,
})
}
pub fn sign(key: &SigningKey, kid: &str, body: &[u8], at_ms: i64) -> SigHeaders {
let sig = key.sign(&signed_material(at_ms, body));
SigHeaders {
sig_b64: Some(base64_encode(&sig.to_bytes())),
kid: Some(kid.to_owned()),
alg: Some(ALG_ED25519.to_owned()),
at_ms: Some(at_ms.to_string()),
}
}
pub struct Signer {
key: SigningKey,
kid: String,
}
impl Signer {
pub fn new(key: SigningKey, kid: impl Into<String>) -> Self {
Self {
key,
kid: kid.into(),
}
}
pub fn from_secret(secret: &str, kid: &str) -> Result<Self, String> {
if kid.trim().is_empty() {
return Err("the signing key id is empty".to_string());
}
Ok(Self::new(decode_secret(secret)?, kid))
}
pub fn kid(&self) -> &str {
&self.kid
}
pub fn verifying_key(&self) -> VerifyingKey {
self.key.verifying_key()
}
pub fn headers(&self, body: &[u8], at_ms: i64) -> SigHeaders {
sign(&self.key, &self.kid, body, at_ms)
}
}
impl std::fmt::Debug for Signer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Signer").field("kid", &self.kid).finish()
}
}
pub const REG_BACKEND_SUBKEY: &str = r"SOFTWARE\kanade\backend";
pub const REG_SIGNING_KEY: &str = "CommandSigningKey";
pub const REG_SIGNING_KID: &str = "CommandSigningKid";
pub fn generate_keypair() -> Result<SigningKey, String> {
let mut seed = [0u8; 32];
getrandom::fill(&mut seed).map_err(|e| format!("OS randomness unavailable: {e}"))?;
Ok(SigningKey::from_bytes(&seed))
}
pub fn encode_secret(key: &SigningKey) -> String {
base64_encode(&key.to_bytes())
}
pub fn decode_secret(raw: &str) -> Result<SigningKey, String> {
let bytes = base64_decode(raw)?;
let arr: [u8; 32] = bytes
.as_slice()
.try_into()
.map_err(|_| format!("signing key must be 32 bytes, got {}", bytes.len()))?;
Ok(SigningKey::from_bytes(&arr))
}
pub fn encode_public(key: &VerifyingKey) -> String {
base64_encode(key.as_bytes())
}
pub fn keyring_entry(kid: &str, key: &VerifyingKey, label: &str) -> serde_json::Value {
serde_json::json!({
"kid": kid,
"public_key": encode_public(key),
"label": label,
})
}
fn base64_encode(bytes: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(s)
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
const NOW: i64 = 1_700_000_000_000;
fn keypair(seed: u8) -> (SigningKey, VerifyingKey) {
let sk = SigningKey::from_bytes(&[seed; 32]);
let vk = sk.verifying_key();
(sk, vk)
}
fn ring_with(kid: &str, vk: VerifyingKey) -> KeyRing {
let mut r = KeyRing::new();
r.insert(kid, vk, KeyPolicy::backend("backend"));
r
}
#[test]
fn round_trip_accepts_the_bytes_that_were_signed() {
let (sk, vk) = keypair(1);
let ring = ring_with("backend-1", vk);
let body = br#"{"id":"job","request_id":"r1"}"#;
let headers = sign(&sk, "backend-1", body, NOW);
let ok = verify(&ring, body, &headers, NOW).expect("verifies");
assert_eq!(ok.kid, "backend-1");
assert_eq!(ok.policy.label, "backend");
}
#[test]
fn a_single_flipped_byte_fails() {
let (sk, vk) = keypair(1);
let ring = ring_with("backend-1", vk);
let headers = sign(&sk, "backend-1", b"run this", NOW);
assert_eq!(
verify(&ring, b"run thit", &headers, NOW),
Err(VerifyError::BadSignature {
kid: "backend-1".into()
})
);
}
#[test]
fn a_forged_command_from_another_key_fails() {
let (_, backend_vk) = keypair(1);
let (attacker_sk, _) = keypair(9);
let ring = ring_with("backend-1", backend_vk);
let body = b"malicious";
let headers = sign(&attacker_sk, "backend-1", body, NOW);
assert_eq!(
verify(&ring, body, &headers, NOW),
Err(VerifyError::BadSignature {
kid: "backend-1".into()
})
);
}
#[test]
fn unsigned_is_distinct_from_every_failure() {
let (_, vk) = keypair(1);
let ring = ring_with("backend-1", vk);
assert_eq!(
verify(&ring, b"anything", &SigHeaders::default(), NOW),
Err(VerifyError::Unsigned)
);
}
#[test]
fn an_unknown_kid_is_its_own_error() {
let (sk, vk) = keypair(1);
let ring = ring_with("backend-1", vk);
let headers = sign(&sk, "backend-2", b"body", NOW);
assert_eq!(
verify(&ring, b"body", &headers, NOW),
Err(VerifyError::UnknownKid {
kid: "backend-2".into()
})
);
}
#[test]
fn rotation_is_two_kids_on_one_ring() {
let (old_sk, old_vk) = keypair(1);
let (new_sk, new_vk) = keypair(2);
let mut ring = ring_with("backend-1", old_vk);
ring.insert("backend-2", new_vk, KeyPolicy::backend("backend (new)"));
for (sk, kid) in [(&old_sk, "backend-1"), (&new_sk, "backend-2")] {
let headers = sign(sk, kid, b"during the window", NOW);
assert_eq!(
verify(&ring, b"during the window", &headers, NOW)
.unwrap()
.kid,
kid
);
}
}
#[test]
fn policies_are_per_key_not_per_ring() {
let (_, backend_vk) = keypair(1);
let (bg_sk, bg_vk) = keypair(3);
let mut ring = ring_with("backend-1", backend_vk);
ring.insert(
"break-glass",
bg_vk,
KeyPolicy::break_glass("break-glass", Duration::from_secs(300)),
);
let headers = sign(&bg_sk, "break-glass", b"emergency", NOW);
let ok = verify(&ring, b"emergency", &headers, NOW).unwrap();
assert!(
ok.policy.audit_every_use,
"break-glass use must be recorded"
);
assert_eq!(ok.policy.max_age, Some(Duration::from_secs(300)));
let (sk, vk) = keypair(1);
let mut r2 = KeyRing::new();
r2.insert("backend-1", vk, KeyPolicy::backend("backend"));
let h = sign(&sk, "backend-1", b"routine", NOW);
assert!(
!verify(&r2, b"routine", &h, NOW)
.unwrap()
.policy
.audit_every_use
);
}
#[test]
fn partial_or_unreadable_headers_are_rejected_rather_than_ignored() {
let (_, vk) = keypair(1);
let ring = ring_with("backend-1", vk);
let no_kid = SigHeaders {
sig_b64: Some("AAAA".into()),
kid: None,
alg: None,
at_ms: None,
};
assert!(matches!(
verify(&ring, b"x", &no_kid, NOW),
Err(VerifyError::Malformed(_))
));
let no_sig = SigHeaders {
sig_b64: None,
kid: Some("backend-1".into()),
alg: None,
at_ms: None,
};
assert!(matches!(
verify(&ring, b"x", &no_sig, NOW),
Err(VerifyError::Malformed(_))
));
let alg_only = SigHeaders {
sig_b64: None,
kid: None,
alg: Some(ALG_ED25519.into()),
at_ms: None,
};
assert!(matches!(
verify(&ring, b"x", &alg_only, NOW),
Err(VerifyError::Malformed(_))
));
let junk = SigHeaders {
sig_b64: Some("!!!not base64!!!".into()),
kid: Some("backend-1".into()),
alg: None,
at_ms: Some(NOW.to_string()),
};
assert!(matches!(
verify(&ring, b"x", &junk, NOW),
Err(VerifyError::Malformed(_))
));
}
#[test]
fn an_unexpected_algorithm_is_refused_not_assumed() {
let (sk, vk) = keypair(1);
let ring = ring_with("backend-1", vk);
let mut headers = sign(&sk, "backend-1", b"body", NOW);
headers.alg = Some("hmac-sha256".into());
assert_eq!(
verify(&ring, b"body", &headers, NOW),
Err(VerifyError::UnsupportedAlg {
alg: "hmac-sha256".into()
})
);
}
#[test]
fn an_absent_alg_header_means_the_original_shape() {
let (sk, vk) = keypair(1);
let ring = ring_with("backend-1", vk);
let mut headers = sign(&sk, "backend-1", b"body", NOW);
headers.alg = None;
assert!(verify(&ring, b"body", &headers, NOW).is_ok());
}
#[test]
fn the_signing_time_is_covered_by_the_signature() {
let (sk, vk) = keypair(1);
let ring = ring_with("backend-1", vk);
let mut headers = sign(&sk, "backend-1", b"body", NOW);
headers.at_ms = Some((NOW + 1).to_string());
assert_eq!(
verify(&ring, b"body", &headers, NOW),
Err(VerifyError::BadSignature {
kid: "backend-1".into()
})
);
}
#[test]
fn the_ordinary_signer_accepts_a_week_old_command() {
let (sk, vk) = keypair(1);
let ring = ring_with("backend-1", vk);
let week = 7 * 24 * 60 * 60 * 1000;
let headers = sign(&sk, "backend-1", b"scheduled job", NOW - week);
assert!(verify(&ring, b"scheduled job", &headers, NOW).is_ok());
}
#[test]
fn a_bounded_key_rejects_an_old_signature() {
let (sk, vk) = keypair(3);
let mut ring = KeyRing::new();
ring.insert(
"break-glass",
vk,
KeyPolicy::break_glass("break-glass", Duration::from_secs(300)),
);
let fresh = sign(&sk, "break-glass", b"emergency", NOW - 60_000);
assert!(verify(&ring, b"emergency", &fresh, NOW).is_ok());
let old = sign(&sk, "break-glass", b"emergency", NOW - 600_000);
assert_eq!(
verify(&ring, b"emergency", &old, NOW),
Err(VerifyError::Stale {
kid: "break-glass".into(),
age_ms: 600_000,
max_age_ms: 300_000,
})
);
}
#[test]
fn a_signature_from_the_future_is_not_fresh() {
let (sk, vk) = keypair(3);
let mut ring = KeyRing::new();
ring.insert(
"break-glass",
vk,
KeyPolicy::break_glass("break-glass", Duration::from_secs(300)),
);
let ahead = sign(&sk, "break-glass", b"emergency", NOW + 3_600_000);
assert!(matches!(
verify(&ring, b"emergency", &ahead, NOW),
Err(VerifyError::Stale { .. })
));
let skewed = sign(&sk, "break-glass", b"emergency", NOW + 5_000);
assert!(verify(&ring, b"emergency", &skewed, NOW).is_ok());
}
#[test]
fn freshness_is_only_judged_after_the_signature_holds() {
let (_, vk) = keypair(3);
let mut ring = KeyRing::new();
ring.insert(
"break-glass",
vk,
KeyPolicy::break_glass("break-glass", Duration::from_secs(300)),
);
let forged = SigHeaders {
sig_b64: Some(base64_encode(&[7u8; 64])),
kid: Some("break-glass".into()),
alg: Some(ALG_ED25519.into()),
at_ms: Some((NOW - 600_000).to_string()),
};
assert_eq!(
verify(&ring, b"x", &forged, NOW),
Err(VerifyError::BadSignature {
kid: "break-glass".into()
})
);
}
#[test]
fn a_missing_or_unparseable_signing_time_is_malformed() {
let (sk, vk) = keypair(1);
let ring = ring_with("backend-1", vk);
let mut no_at = sign(&sk, "backend-1", b"body", NOW);
no_at.at_ms = None;
assert!(matches!(
verify(&ring, b"body", &no_at, NOW),
Err(VerifyError::Malformed(_))
));
let mut junk_at = sign(&sk, "backend-1", b"body", NOW);
junk_at.at_ms = Some("yesterday".into());
assert!(matches!(
verify(&ring, b"body", &junk_at, NOW),
Err(VerifyError::Malformed(_))
));
let at_only = SigHeaders {
sig_b64: None,
kid: None,
alg: None,
at_ms: Some(NOW.to_string()),
};
assert!(matches!(
verify(&ring, b"body", &at_only, NOW),
Err(VerifyError::Malformed(_))
));
}
#[test]
fn an_absurd_bound_neither_panics_nor_inverts() {
let (sk, vk) = keypair(5);
let mut ring = KeyRing::new();
ring.insert(
"silly",
vk,
KeyPolicy::break_glass("silly", Duration::from_secs(u64::MAX / 1000)),
);
let headers = sign(&sk, "silly", b"body", NOW - 10_000);
assert!(
verify(&ring, b"body", &headers, NOW).is_ok(),
"an enormous bound must read as permissive, not as inverted"
);
}
#[test]
fn a_generated_key_round_trips_through_its_encoded_secret() {
let key = generate_keypair().expect("OS randomness");
let restored = decode_secret(&encode_secret(&key)).expect("decodes");
assert_eq!(restored.to_bytes(), key.to_bytes());
let ring = ring_with("backend-1", key.verifying_key());
let headers = sign(&restored, "backend-1", b"after a restart", NOW);
assert!(verify(&ring, b"after a restart", &headers, NOW).is_ok());
}
#[test]
fn a_signer_built_from_the_stored_secret_verifies_against_its_own_ring() {
let key = generate_keypair().unwrap();
let signer = Signer::from_secret(&encode_secret(&key), "backend-1").expect("builds");
let ring = ring_with("backend-1", key.verifying_key());
let body = br#"{"id":"job","request_id":"r1"}"#;
let headers = signer.headers(body, NOW);
assert_eq!(verify(&ring, body, &headers, NOW).unwrap().kid, "backend-1");
}
#[test]
fn a_signer_refuses_an_empty_kid_rather_than_signing_under_one() {
let secret = encode_secret(&generate_keypair().unwrap());
assert!(Signer::from_secret(&secret, "").is_err());
assert!(Signer::from_secret(&secret, " ").is_err());
assert!(Signer::from_secret("not base64!!", "backend-1").is_err());
}
#[test]
fn debugging_a_signer_never_prints_the_key() {
let key = generate_keypair().unwrap();
let signer = Signer::new(key.clone(), "backend-1");
assert_eq!(format!("{signer:?}"), r#"Signer { kid: "backend-1" }"#);
assert!(!format!("{signer:?}").contains(&encode_secret(&key)));
}
#[test]
fn two_generated_keys_differ() {
let a = generate_keypair().unwrap();
let b = generate_keypair().unwrap();
assert_ne!(a.to_bytes(), b.to_bytes());
}
#[test]
fn a_malformed_secret_is_rejected_with_its_length() {
assert!(decode_secret("not base64!!").is_err());
let short = base64_encode(&[0u8; 31]);
let err = decode_secret(&short).unwrap_err();
assert!(
err.contains("31"),
"the error should name the length: {err}"
);
}
#[test]
fn the_emitted_keyring_entry_is_what_the_agent_parses() {
let key = generate_keypair().unwrap();
let entry = keyring_entry("backend-20260728", &key.verifying_key(), "backend");
assert_eq!(entry["kid"], "backend-20260728");
assert_eq!(entry["label"], "backend");
let pk = entry["public_key"]
.as_str()
.expect("public_key is a string");
assert_eq!(pk, encode_public(&key.verifying_key()));
assert_eq!(pk.len(), 44);
}
}