use base64::Engine;
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::{Arc, Mutex};
pub const H_KEY: &str = "x-car-peer-key";
pub const H_TS: &str = "x-car-peer-ts";
pub const H_NONCE: &str = "x-car-peer-nonce";
pub const H_SIG: &str = "x-car-peer-sig";
pub const CLOCK_SKEW_MS: u64 = 60_000;
const B64: base64::engine::general_purpose::GeneralPurpose =
base64::engine::general_purpose::STANDARD_NO_PAD;
pub struct PeerIdentity {
signing: SigningKey,
}
impl PeerIdentity {
pub fn load_or_generate(path: &Path) -> Result<Self, String> {
if let Ok(raw) = std::fs::read(path) {
if raw.len() == 32 {
let mut b = [0u8; 32];
b.copy_from_slice(&raw);
return Ok(Self {
signing: SigningKey::from_bytes(&b),
});
}
return Err(format!(
"{} is not a 32-byte ed25519 key ({} bytes); refusing to replace it",
path.display(),
raw.len()
));
}
let signing = SigningKey::generate(&mut rand_core::OsRng);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("create key dir: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
}
}
std::fs::write(path, signing.to_bytes()).map_err(|e| format!("write key: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
Ok(Self { signing })
}
pub fn from_bytes(b: [u8; 32]) -> Self {
Self {
signing: SigningKey::from_bytes(&b),
}
}
pub fn public_key(&self) -> String {
B64.encode(self.signing.verifying_key().to_bytes())
}
pub fn fingerprint(&self) -> String {
fingerprint_of(&self.public_key())
}
pub fn sign(
&self,
method: &str,
path: &str,
body: &[u8],
now_ms: u64,
) -> Vec<(String, String)> {
let nonce = B64.encode(uuid_bytes());
let canonical = canonical_string(method, path, now_ms, &nonce, body);
let sig = self.signing.sign(canonical.as_bytes());
vec![
(H_KEY.to_string(), self.public_key()),
(H_TS.to_string(), now_ms.to_string()),
(H_NONCE.to_string(), nonce),
(H_SIG.to_string(), B64.encode(sig.to_bytes())),
]
}
}
fn uuid_bytes() -> [u8; 16] {
*uuid::Uuid::new_v4().as_bytes()
}
pub fn fingerprint_of(public_key_b64: &str) -> String {
let digest = Sha256::digest(public_key_b64.as_bytes());
digest[..4]
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(":")
}
fn canonical_string(method: &str, path: &str, ts_ms: u64, nonce: &str, body: &[u8]) -> String {
let body_hash = Sha256::digest(body);
format!(
"{}\n{}\n{}\n{}\n{}",
method.to_ascii_uppercase(),
path,
ts_ms,
nonce,
B64.encode(body_hash)
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PeerAuthError {
MissingHeaders,
Malformed(String),
BadSignature,
StaleTimestamp,
Replay,
UntrustedKey { fingerprint: String },
}
impl PeerAuthError {
pub fn message(&self) -> String {
match self {
PeerAuthError::MissingHeaders => {
"peer authentication headers are missing; only CAR peers may call this surface"
.into()
}
PeerAuthError::Malformed(w) => format!("malformed peer credential: {w}"),
PeerAuthError::BadSignature => "peer signature did not verify".into(),
PeerAuthError::StaleTimestamp => {
format!("peer request timestamp is outside the {CLOCK_SKEW_MS}ms window")
}
PeerAuthError::Replay => "peer request nonce was already used".into(),
PeerAuthError::UntrustedKey { fingerprint } => format!(
"peer key {fingerprint} is not trusted by this host; add it with a2a.peers.add \
after comparing the fingerprint"
),
}
}
}
#[derive(Clone)]
pub struct PeerTrust {
trusted: Arc<Mutex<HashSet<String>>>,
seen: Arc<Mutex<HashMap<String, u64>>>,
}
impl Default for PeerTrust {
fn default() -> Self {
Self::new()
}
}
impl PeerTrust {
pub fn new() -> Self {
Self {
trusted: Arc::new(Mutex::new(HashSet::new())),
seen: Arc::new(Mutex::new(HashMap::new())),
}
}
pub fn set_trusted(&self, keys: impl IntoIterator<Item = String>) {
let mut t = self.trusted.lock().unwrap_or_else(|e| e.into_inner());
*t = keys.into_iter().collect();
}
pub fn trusted_count(&self) -> usize {
self.trusted.lock().unwrap_or_else(|e| e.into_inner()).len()
}
pub fn is_trusted(&self, public_key_b64: &str) -> bool {
self.trusted
.lock()
.unwrap_or_else(|e| e.into_inner())
.contains(public_key_b64)
}
pub fn verify(
&self,
headers: &PeerHeaders,
method: &str,
path: &str,
body: &[u8],
now_ms: u64,
) -> Result<String, PeerAuthError> {
let key_bytes = B64
.decode(&headers.key)
.map_err(|e| PeerAuthError::Malformed(format!("key: {e}")))?;
let key_arr: [u8; 32] = key_bytes
.try_into()
.map_err(|_| PeerAuthError::Malformed("key is not 32 bytes".into()))?;
let verifying = VerifyingKey::from_bytes(&key_arr)
.map_err(|e| PeerAuthError::Malformed(format!("key: {e}")))?;
let ts: u64 = headers
.ts
.parse()
.map_err(|_| PeerAuthError::Malformed("timestamp is not a number".into()))?;
if now_ms.abs_diff(ts) > CLOCK_SKEW_MS {
return Err(PeerAuthError::StaleTimestamp);
}
let sig_bytes = B64
.decode(&headers.sig)
.map_err(|e| PeerAuthError::Malformed(format!("signature: {e}")))?;
let sig_arr: [u8; 64] = sig_bytes
.try_into()
.map_err(|_| PeerAuthError::Malformed("signature is not 64 bytes".into()))?;
let signature = Signature::from_bytes(&sig_arr);
let canonical = canonical_string(method, path, ts, &headers.nonce, body);
verifying
.verify(canonical.as_bytes(), &signature)
.map_err(|_| PeerAuthError::BadSignature)?;
if !self.is_trusted(&headers.key) {
return Err(PeerAuthError::UntrustedKey {
fingerprint: fingerprint_of(&headers.key),
});
}
{
let mut seen = self.seen.lock().unwrap_or_else(|e| e.into_inner());
seen.retain(|_, t| now_ms.saturating_sub(*t) <= CLOCK_SKEW_MS);
if seen.contains_key(&headers.nonce) {
return Err(PeerAuthError::Replay);
}
seen.insert(headers.nonce.clone(), now_ms);
}
Ok(headers.key.clone())
}
}
#[derive(Debug, Clone)]
pub struct PeerHeaders {
pub key: String,
pub ts: String,
pub nonce: String,
pub sig: String,
}
impl PeerHeaders {
pub fn from_map(get: impl Fn(&str) -> Option<String>) -> Option<Self> {
Some(Self {
key: get(H_KEY)?,
ts: get(H_TS)?,
nonce: get(H_NONCE)?,
sig: get(H_SIG)?,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ident(seed: u8) -> PeerIdentity {
PeerIdentity::from_bytes([seed; 32])
}
fn headers(v: Vec<(String, String)>) -> PeerHeaders {
let map: HashMap<String, String> = v.into_iter().collect();
PeerHeaders::from_map(|k| map.get(k).cloned()).expect("all four headers")
}
#[test]
fn a_trusted_peers_signature_is_accepted() {
let me = ident(1);
let trust = PeerTrust::new();
trust.set_trusted([me.public_key()]);
let h = headers(me.sign("POST", "/a2a", b"hello", 1_000));
assert_eq!(
trust.verify(&h, "POST", "/a2a", b"hello", 1_000).unwrap(),
me.public_key()
);
}
#[test]
fn a_stranger_with_a_valid_signature_is_still_refused() {
let stranger = ident(9);
let trust = PeerTrust::new();
trust.set_trusted([ident(1).public_key()]);
let h = headers(stranger.sign("POST", "/a2a", b"hello", 1_000));
assert!(matches!(
trust.verify(&h, "POST", "/a2a", b"hello", 1_000),
Err(PeerAuthError::UntrustedKey { .. })
));
}
#[test]
fn a_tampered_body_breaks_the_signature() {
let me = ident(1);
let trust = PeerTrust::new();
trust.set_trusted([me.public_key()]);
let h = headers(me.sign("POST", "/a2a", b"original", 1_000));
assert_eq!(
trust.verify(&h, "POST", "/a2a", b"tampered", 1_000),
Err(PeerAuthError::BadSignature)
);
}
#[test]
fn a_signature_cannot_be_replayed_against_another_path_or_method() {
let me = ident(1);
let trust = PeerTrust::new();
trust.set_trusted([me.public_key()]);
let h = headers(me.sign("POST", "/a2a", b"x", 1_000));
assert_eq!(
trust.verify(&h, "POST", "/admin", b"x", 1_000),
Err(PeerAuthError::BadSignature)
);
assert_eq!(
trust.verify(&h, "DELETE", "/a2a", b"x", 1_000),
Err(PeerAuthError::BadSignature)
);
}
#[test]
fn a_stale_request_is_refused() {
let me = ident(1);
let trust = PeerTrust::new();
trust.set_trusted([me.public_key()]);
let h = headers(me.sign("POST", "/a2a", b"x", 1_000));
let much_later = 1_000 + CLOCK_SKEW_MS + 1;
assert_eq!(
trust.verify(&h, "POST", "/a2a", b"x", much_later),
Err(PeerAuthError::StaleTimestamp)
);
let h2 = headers(me.sign("POST", "/a2a", b"x", much_later));
assert_eq!(
trust.verify(&h2, "POST", "/a2a", b"x", 1_000),
Err(PeerAuthError::StaleTimestamp)
);
}
#[test]
fn a_captured_request_cannot_be_replayed_inside_the_window() {
let me = ident(1);
let trust = PeerTrust::new();
trust.set_trusted([me.public_key()]);
let h = headers(me.sign("POST", "/a2a", b"x", 1_000));
assert!(trust.verify(&h, "POST", "/a2a", b"x", 1_000).is_ok());
assert_eq!(
trust.verify(&h, "POST", "/a2a", b"x", 1_500),
Err(PeerAuthError::Replay)
);
}
#[test]
fn revoking_a_key_stops_it_immediately() {
let me = ident(1);
let trust = PeerTrust::new();
trust.set_trusted([me.public_key()]);
assert!(trust
.verify(
&headers(me.sign("POST", "/a2a", b"x", 1_000)),
"POST",
"/a2a",
b"x",
1_000
)
.is_ok());
trust.set_trusted(Vec::<String>::new());
assert!(matches!(
trust.verify(
&headers(me.sign("POST", "/a2a", b"y", 2_000)),
"POST",
"/a2a",
b"y",
2_000
),
Err(PeerAuthError::UntrustedKey { .. })
));
}
#[test]
fn missing_credentials_are_refused_rather_than_defaulting_open() {
let empty: HashMap<String, String> = HashMap::new();
assert!(PeerHeaders::from_map(|k| empty.get(k).cloned()).is_none());
}
#[test]
fn a_corrupt_key_file_is_not_silently_replaced() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("peer-identity.key");
std::fs::write(&path, b"not-a-key").unwrap();
assert!(PeerIdentity::load_or_generate(&path).is_err());
}
#[test]
fn an_identity_persists_across_loads() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("peer-identity.key");
let a = PeerIdentity::load_or_generate(&path).unwrap();
let b = PeerIdentity::load_or_generate(&path).unwrap();
assert_eq!(a.public_key(), b.public_key());
assert_eq!(a.fingerprint(), b.fingerprint());
}
#[test]
fn fingerprints_are_short_stable_and_key_specific() {
let a = ident(1).fingerprint();
assert_eq!(a, ident(1).fingerprint());
assert_ne!(a, ident(2).fingerprint());
assert_eq!(a.len(), 11, "four hex bytes joined by colons");
}
}