use base64::Engine;
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
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("signature invalid")]
BadSignature,
#[error("verifying key error: {0}")]
Key(String),
#[error("stale version: {got} <= current {current}")]
Stale { got: u64, current: u64 },
}
#[derive(Clone)]
pub struct SnapshotVerifier {
key: VerifyingKey,
}
impl SnapshotVerifier {
pub fn from_env() -> Result<Self, anyhow::Error> {
let b64 = std::env::var("AUTHZ_SNAPSHOT_PUBKEY")
.map_err(|_| anyhow::anyhow!("AUTHZ_SNAPSHOT_PUBKEY is required"))?;
Self::from_base64(b64.trim())
}
pub fn from_base64(b64: &str) -> Result<Self, anyhow::Error> {
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| anyhow::anyhow!("AUTHZ_SNAPSHOT_PUBKEY not valid base64: {e}"))?;
let bytes: [u8; 32] = bytes
.as_slice()
.try_into()
.map_err(|_| anyhow::anyhow!("AUTHZ_SNAPSHOT_PUBKEY must be 32 bytes"))?;
let key = VerifyingKey::from_bytes(&bytes)
.map_err(|e| anyhow::anyhow!("invalid Ed25519 public key: {e}"))?;
Ok(Self { key })
}
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()))?;
let sig_bytes = base64::engine::general_purpose::STANDARD
.decode(envelope.signature.trim())
.map_err(|e| VerifyError::Key(format!("signature base64: {e}")))?;
let sig_bytes: [u8; 64] = sig_bytes
.as_slice()
.try_into()
.map_err(|_| VerifyError::BadSignature)?;
let signature = Signature::from_bytes(&sig_bytes);
self.key
.verify(envelope.payload.as_bytes(), &signature)
.map_err(|_| VerifyError::BadSignature)?;
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)
}
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine as _;
use ed25519_dalek::{Signer, SigningKey};
fn make_envelope(signing: &SigningKey, version: u64) -> Vec<u8> {
let payload = serde_json::json!({
"schema": SCHEMA,
"policy_version": version,
"issued_at": "2026-07-08T00:00:00Z",
"permissions": [],
"roles": [],
"grants": [],
"bindings": []
});
let payload_str = serde_json::to_string(&payload).unwrap();
let sig = signing.sign(payload_str.as_bytes());
let envelope = serde_json::json!({
"payload": payload_str,
"signature": base64::engine::general_purpose::STANDARD.encode(sig.to_bytes()),
"key_id": "authz-v1"
});
serde_json::to_vec(&envelope).unwrap()
}
#[test]
fn valid_envelope_verifies_and_enforces_monotonicity() {
let signing = SigningKey::from_bytes(&[7u8; 32]);
let pub_b64 = base64::engine::general_purpose::STANDARD
.encode(signing.verifying_key().to_bytes());
let verifier = SnapshotVerifier::from_base64(&pub_b64).unwrap();
let env = make_envelope(&signing, 5);
let payload = verifier.verify(&env, 0).unwrap();
assert_eq!(payload.policy_version, 5);
assert!(matches!(
verifier.verify(&env, 5),
Err(VerifyError::Stale { got: 5, current: 5 })
));
}
#[test]
fn wrong_key_rejected() {
let signing = SigningKey::from_bytes(&[7u8; 32]);
let other = SigningKey::from_bytes(&[9u8; 32]);
let pub_b64 = base64::engine::general_purpose::STANDARD
.encode(other.verifying_key().to_bytes());
let verifier = SnapshotVerifier::from_base64(&pub_b64).unwrap();
let env = make_envelope(&signing, 1);
assert!(matches!(verifier.verify(&env, 0), Err(VerifyError::BadSignature)));
}
}