road-runner-common 0.7.0

Shared Rust utilities for exchange ecosystem backend services.
Documentation
//! Snapshot envelope verification: parse, verify the Ed25519 signature over the
//! exact payload bytes, and enforce monotonic versioning.
//!
//! Public key comes from `AUTHZ_SNAPSHOT_PUBKEY` (base64 of a 32-byte Ed25519 key),
//! matching cex-policy's `AUTHZ_SNAPSHOT_SIGNING_KEY`. Fail-closed: a
//! missing/invalid key or bad signature rejects the snapshot.

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 },
}

/// The verifying key, loaded once from the environment.
#[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 })
    }

    /// Verify a raw envelope's signature and parse its payload. `current_version` is
    /// what the PDP already holds; a snapshot must strictly advance it.
    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};

    // Mirrors cex-policy's SnapshotSigner: sign the exact payload string bytes, wrap
    // in {payload, signature, key_id}. Locks the cross-service signing contract.
    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);

        // Same version against a caller already at 5 => stale.
        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)));
    }
}