road-runner-common 0.23.0

Shared Rust utilities for exchange ecosystem backend services.
Documentation
//! Snapshot envelope parsing, signature verification, and monotonic version
//! enforcement.
//!
//! The Ed25519 signature over the payload is verified according to a
//! [`VerifyMode`] resolved from `AUTHZ_SNAPSHOT_VERIFY`:
//!
//! - `off` (default): parse only — the historical channel-trusted posture
//!   (mesh mTLS / broker ACLs), unchanged for services that have not opted in.
//! - `warn`: verify and log a loud warning on a missing/invalid signature, but
//!   accept the snapshot. The rollout bake mode: consumers run here while the
//!   publisher's signed envelopes prove themselves out.
//! - `enforce`: a missing or invalid signature rejects the snapshot; the PDP
//!   keeps its last-good state, exactly like a `Stale` version.
//!
//! Keys come from `AUTHZ_SNAPSHOT_PUBLIC_KEYS` (comma-separated
//! `key_id=pubkey_b64` pairs) or the single-key shorthand
//! `AUTHZ_SNAPSHOT_PUBLIC_KEY_B64` (key id defaults to `default`). Carrying a
//! map keyed by `key_id` is what makes rotation a config change: publish with
//! the new key id while consumers hold both, then drop the old.
//!
//! The signature preimage is the exact serialized `payload` string carried in
//! the envelope — no canonicalization step to disagree over.

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

/// How strictly the payload signature is checked.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerifyMode {
    Off,
    Warn,
    Enforce,
}

/// Parses authz snapshot envelopes, verifies the publisher signature per the
/// configured [`VerifyMode`], and enforces schema + monotonic version.
#[derive(Clone, Default)]
pub struct SnapshotVerifier {
    mode: Option<VerifyMode>,
    keys: HashMap<String, VerifyingKey>,
}

impl SnapshotVerifier {
    /// Keyless verifier (mode `Off`) — the historical constructor, kept so
    /// existing call sites keep their exact behavior until they opt in via
    /// [`SnapshotVerifier::from_env`].
    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 }
    }

    /// Resolve mode and keys from the environment (`AUTHZ_SNAPSHOT_VERIFY`,
    /// `AUTHZ_SNAPSHOT_PUBLIC_KEYS` / `AUTHZ_SNAPSHOT_PUBLIC_KEY_B64`).
    ///
    /// Fails loudly on a mode that names verification but has no usable key —
    /// a `warn`/`enforce` verifier with an empty key map would report every
    /// snapshot as unsigned, which is indistinguishable from an attack.
    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)
    }

    /// Parse a raw envelope and 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()))?;

        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}'"))?,
            // No key_id on the wire: accept iff exactly one key is configured.
            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);
        // Strictly-newer required.
        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");

        // Valid signature passes.
        assert!(v.verify(&signed_envelope(3, &key, "authz-1"), 0).is_ok());
        // Unsigned envelope rejected.
        assert!(matches!(
            v.verify(&make_envelope(3), 0),
            Err(VerifyError::Signature(_))
        ));
        // Signature from a different key rejected.
        let other = SigningKey::from_bytes(&[9u8; 32]);
        assert!(matches!(
            v.verify(&signed_envelope(3, &other, "authz-1"), 0),
            Err(VerifyError::Signature(_))
        ));
        // Unknown key id rejected.
        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]);
        // Warn: unsigned still accepted (logged).
        let v = verifier(VerifyMode::Warn, &key, "authz-1");
        assert!(v.verify(&make_envelope(4), 0).is_ok());
        // Off: forged envelope accepted without a key even configured.
        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");
        // Sign version 3, then swap the payload for version 4 — signature must fail.
        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(_))
        ));
    }
}