use base64::Engine as _;
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use std::collections::HashMap;
use super::model::{SnapshotEnvelope, SnapshotPayload};
const SCHEMA: &str = "cex.authz.snapshot.v1";
#[derive(Debug, thiserror::Error)]
pub enum VerifyError {
#[error("envelope parse failed: {0}")]
Envelope(String),
#[error("payload parse failed: {0}")]
Payload(String),
#[error("unexpected schema: {0}")]
Schema(String),
#[error("stale version: {got} <= current {current}")]
Stale { got: u64, current: u64 },
#[error("signature rejected: {0}")]
Signature(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerifyMode {
Off,
Warn,
Enforce,
}
#[derive(Clone, Default)]
pub struct SnapshotVerifier {
mode: Option<VerifyMode>,
keys: HashMap<String, VerifyingKey>,
}
impl SnapshotVerifier {
pub fn new() -> Self {
Self { mode: Some(VerifyMode::Off), keys: HashMap::new() }
}
pub fn with_keys(mode: VerifyMode, keys: HashMap<String, VerifyingKey>) -> Self {
Self { mode: Some(mode), keys }
}
pub fn from_env() -> Result<Self, String> {
let mode = match std::env::var("AUTHZ_SNAPSHOT_VERIFY")
.unwrap_or_default()
.trim()
.to_lowercase()
.as_str()
{
"" | "off" => VerifyMode::Off,
"warn" => VerifyMode::Warn,
"enforce" => VerifyMode::Enforce,
other => return Err(format!("invalid AUTHZ_SNAPSHOT_VERIFY '{other}' (off|warn|enforce)")),
};
let mut keys = HashMap::new();
if let Ok(list) = std::env::var("AUTHZ_SNAPSHOT_PUBLIC_KEYS") {
for pair in list.split(',').map(str::trim).filter(|s| !s.is_empty()) {
let (id, b64) = pair
.split_once('=')
.ok_or_else(|| format!("AUTHZ_SNAPSHOT_PUBLIC_KEYS entry '{pair}' is not key_id=pubkey_b64"))?;
keys.insert(id.trim().to_string(), parse_key(b64.trim())?);
}
}
if let Ok(b64) = std::env::var("AUTHZ_SNAPSHOT_PUBLIC_KEY_B64") {
if !b64.trim().is_empty() {
keys.insert("default".to_string(), parse_key(b64.trim())?);
}
}
if mode != VerifyMode::Off && keys.is_empty() {
return Err(
"AUTHZ_SNAPSHOT_VERIFY is warn/enforce but no public key is configured".to_string(),
);
}
Ok(Self { mode: Some(mode), keys })
}
fn mode(&self) -> VerifyMode {
self.mode.unwrap_or(VerifyMode::Off)
}
pub fn verify(
&self,
envelope_bytes: &[u8],
current_version: u64,
) -> Result<SnapshotPayload, VerifyError> {
let envelope: SnapshotEnvelope = serde_json::from_slice(envelope_bytes)
.map_err(|e| VerifyError::Envelope(e.to_string()))?;
if self.mode() != VerifyMode::Off {
if let Err(reason) = self.check_signature(&envelope) {
match self.mode() {
VerifyMode::Enforce => return Err(VerifyError::Signature(reason)),
_ => tracing::warn!(
target: "security",
reason = %reason,
key_id = envelope.key_id.as_deref().unwrap_or("<none>"),
"authz snapshot signature rejected (warn mode: accepted)"
),
}
}
}
let payload: SnapshotPayload = serde_json::from_str(&envelope.payload)
.map_err(|e| VerifyError::Payload(e.to_string()))?;
if payload.schema != SCHEMA {
return Err(VerifyError::Schema(payload.schema));
}
if payload.policy_version <= current_version && current_version != 0 {
return Err(VerifyError::Stale {
got: payload.policy_version,
current: current_version,
});
}
Ok(payload)
}
fn check_signature(&self, envelope: &SnapshotEnvelope) -> Result<(), String> {
let sig_b64 = envelope
.signature
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| "envelope carries no signature".to_string())?;
let key = match envelope.key_id.as_deref() {
Some(id) => self
.keys
.get(id)
.ok_or_else(|| format!("unknown key_id '{id}'"))?,
None if self.keys.len() == 1 => self.keys.values().next().expect("len checked"),
None => return Err("envelope carries no key_id and multiple keys are configured".into()),
};
let sig_bytes = base64::engine::general_purpose::STANDARD
.decode(sig_b64)
.map_err(|e| format!("signature is not base64: {e}"))?;
let signature = Signature::from_slice(&sig_bytes)
.map_err(|e| format!("signature is not a valid Ed25519 signature: {e}"))?;
key.verify(envelope.payload.as_bytes(), &signature)
.map_err(|_| "Ed25519 verification failed".to_string())
}
}
fn parse_key(b64: &str) -> Result<VerifyingKey, String> {
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| format!("public key is not base64: {e}"))?;
let arr: [u8; 32] = bytes
.as_slice()
.try_into()
.map_err(|_| format!("public key must be 32 bytes, got {}", bytes.len()))?;
VerifyingKey::from_bytes(&arr).map_err(|e| format!("invalid Ed25519 public key: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
fn payload_str(version: u64) -> String {
serde_json::json!({
"schema": SCHEMA,
"policy_version": version,
"issued_at": "2026-07-08T00:00:00Z",
"permissions": [],
"roles": [],
"grants": [],
"bindings": []
})
.to_string()
}
fn make_envelope(version: u64) -> Vec<u8> {
serde_json::to_vec(&serde_json::json!({
"payload": payload_str(version),
"signature": "",
"key_id": "authz-v1"
}))
.unwrap()
}
fn signed_envelope(version: u64, key: &SigningKey, key_id: &str) -> Vec<u8> {
let payload = payload_str(version);
let sig = key.sign(payload.as_bytes());
serde_json::to_vec(&serde_json::json!({
"payload": payload,
"signature": base64::engine::general_purpose::STANDARD.encode(sig.to_bytes()),
"key_id": key_id
}))
.unwrap()
}
fn verifier(mode: VerifyMode, key: &SigningKey, key_id: &str) -> SnapshotVerifier {
let mut keys = HashMap::new();
keys.insert(key_id.to_string(), key.verifying_key());
SnapshotVerifier::with_keys(mode, keys)
}
#[test]
fn parses_and_enforces_monotonic_version() {
let v = SnapshotVerifier::new();
let p = v.verify(&make_envelope(5), 0).expect("first snapshot");
assert_eq!(p.policy_version, 5);
assert!(matches!(
v.verify(&make_envelope(5), 5),
Err(VerifyError::Stale { .. })
));
assert!(v.verify(&make_envelope(6), 5).is_ok());
}
#[test]
fn rejects_wrong_schema() {
let v = SnapshotVerifier::new();
let bad = serde_json::to_vec(&serde_json::json!({
"payload": serde_json::json!({"schema":"nope","policy_version":1,"permissions":[],"roles":[],"grants":[],"bindings":[]}).to_string(),
"signature": ""
})).unwrap();
assert!(matches!(v.verify(&bad, 0), Err(VerifyError::Schema(_))));
}
#[test]
fn enforce_accepts_valid_signature_and_rejects_missing_or_forged() {
let key = SigningKey::from_bytes(&[7u8; 32]);
let v = verifier(VerifyMode::Enforce, &key, "authz-1");
assert!(v.verify(&signed_envelope(3, &key, "authz-1"), 0).is_ok());
assert!(matches!(
v.verify(&make_envelope(3), 0),
Err(VerifyError::Signature(_))
));
let other = SigningKey::from_bytes(&[9u8; 32]);
assert!(matches!(
v.verify(&signed_envelope(3, &other, "authz-1"), 0),
Err(VerifyError::Signature(_))
));
assert!(matches!(
v.verify(&signed_envelope(3, &key, "who-dis"), 0),
Err(VerifyError::Signature(_))
));
}
#[test]
fn warn_mode_accepts_but_off_mode_never_checks() {
let key = SigningKey::from_bytes(&[7u8; 32]);
let v = verifier(VerifyMode::Warn, &key, "authz-1");
assert!(v.verify(&make_envelope(4), 0).is_ok());
let off = SnapshotVerifier::new();
assert!(off.verify(&make_envelope(4), 0).is_ok());
}
#[test]
fn tampered_payload_fails_enforce() {
let key = SigningKey::from_bytes(&[7u8; 32]);
let v = verifier(VerifyMode::Enforce, &key, "authz-1");
let payload = payload_str(3);
let sig = key.sign(payload.as_bytes());
let tampered = serde_json::to_vec(&serde_json::json!({
"payload": payload_str(4),
"signature": base64::engine::general_purpose::STANDARD.encode(sig.to_bytes()),
"key_id": "authz-1"
}))
.unwrap();
assert!(matches!(
v.verify(&tampered, 0),
Err(VerifyError::Signature(_))
));
}
}