invoance 0.2.0

Official Rust SDK for the Invoance compliance API
Documentation
//! Offline, client-side signature verification for audit events.
//!
//! Reconstructs the canonical signed bytes from an event returned by the API
//! and checks the Ed25519 signature using `ed25519-dalek`.
//!
//! Trust note: by default this verifies against the key embedded in the event
//! (`signing_public_key`), which only proves the payload is internally
//! consistent with that key. For a real tamper guarantee, pass `public_key` =
//! the tenant's registered key (the server pins it and never trusts the row's
//! key).

use ed25519_dalek::{Signature, VerifyingKey};
use serde_json::{Map, Value};

use crate::audit_canonical::{canonical_audit_bytes, payload_hash_hex};

/// Where the verifying key came from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeySource {
    /// A caller-supplied (pinned) key was used.
    Pinned,
    /// The key embedded in the event was used.
    Event,
}

/// Result of [`verify_audit_event`].
#[derive(Debug, Clone)]
pub struct AuditVerifyResult {
    /// Whether the signature verified successfully.
    pub valid: bool,
    /// Machine-readable reason on failure, `None` when `valid`.
    pub reason: Option<String>,
    /// The recomputed payload hash (empty when canonicalization failed).
    pub payload_hash: String,
    /// Which key was used.
    pub key_source: KeySource,
}

fn ed25519_verify(message: &[u8], signature: &[u8], pubkey: &[u8]) -> bool {
    let key_bytes: [u8; 32] = match pubkey.try_into() {
        Ok(b) => b,
        Err(_) => return false,
    };
    let vk = match VerifyingKey::from_bytes(&key_bytes) {
        Ok(k) => k,
        Err(_) => return false,
    };
    let sig = match Signature::from_slice(signature) {
        Ok(s) => s,
        Err(_) => return false,
    };
    vk.verify_strict(message, &sig).is_ok()
}

/// Verify one audit event's signature offline.
///
/// `event` is the JSON object returned by the API. `public_key`, when supplied,
/// is a hex-encoded raw 32-byte Ed25519 key that pins verification to a trusted
/// key instead of the one embedded in the event.
pub fn verify_audit_event(event: &Value, public_key: Option<&str>) -> AuditVerifyResult {
    let key_source = if public_key.is_some() {
        KeySource::Pinned
    } else {
        KeySource::Event
    };

    let obj = match event.as_object() {
        Some(o) => o,
        None => {
            return AuditVerifyResult {
                valid: false,
                reason: Some("canonicalization_failed".into()),
                payload_hash: String::new(),
                key_source,
            }
        }
    };

    // Build the signed input: org_id, event_id (= id ?? event_id), seq,
    // ingested_at, action, occurred_at, actor, targets; context/metadata only
    // when non-null.
    let mut signed = Map::new();
    let copy = |m: &mut Map<String, Value>, key: &str, val: Option<&Value>| {
        m.insert(key.to_string(), val.cloned().unwrap_or(Value::Null));
    };
    copy(&mut signed, "org_id", obj.get("org_id"));
    let event_id = obj
        .get("id")
        .filter(|v| !v.is_null())
        .or_else(|| obj.get("event_id"));
    signed.insert(
        "event_id".to_string(),
        event_id.cloned().unwrap_or(Value::Null),
    );
    copy(&mut signed, "seq", obj.get("seq"));
    copy(&mut signed, "ingested_at", obj.get("ingested_at"));
    copy(&mut signed, "action", obj.get("action"));
    copy(&mut signed, "occurred_at", obj.get("occurred_at"));
    copy(&mut signed, "actor", obj.get("actor"));
    copy(&mut signed, "targets", obj.get("targets"));
    if let Some(ctx) = obj.get("context").filter(|v| !v.is_null()) {
        signed.insert("context".to_string(), ctx.clone());
    }
    if let Some(md) = obj.get("metadata").filter(|v| !v.is_null()) {
        signed.insert("metadata".to_string(), md.clone());
    }

    let canonical = match canonical_audit_bytes(&Value::Object(signed)) {
        Ok(c) => c,
        Err(_) => {
            return AuditVerifyResult {
                valid: false,
                reason: Some("canonicalization_failed".into()),
                payload_hash: String::new(),
                key_source,
            }
        }
    };

    let recomputed = payload_hash_hex(&canonical);

    if let Some(ph) = obj.get("payload_hash").and_then(Value::as_str) {
        if ph != recomputed {
            return AuditVerifyResult {
                valid: false,
                reason: Some("payload_hash_mismatch".into()),
                payload_hash: recomputed,
                key_source,
            };
        }
    }

    let key_hex = public_key.map(str::to_string).or_else(|| {
        obj.get("signing_public_key")
            .and_then(Value::as_str)
            .map(str::to_string)
    });
    let key_hex = match key_hex {
        Some(k) if !k.is_empty() => k,
        _ => {
            return AuditVerifyResult {
                valid: false,
                reason: Some("no_public_key".into()),
                payload_hash: recomputed,
                key_source,
            }
        }
    };

    let sig_hex = obj.get("signature").and_then(Value::as_str).unwrap_or("");
    if sig_hex.is_empty() {
        return AuditVerifyResult {
            valid: false,
            reason: Some("no_signature".into()),
            payload_hash: recomputed,
            key_source,
        };
    }

    let sig_bytes = match hex::decode(sig_hex) {
        Ok(b) => b,
        Err(_) => {
            return AuditVerifyResult {
                valid: false,
                reason: Some("signature_invalid".into()),
                payload_hash: recomputed,
                key_source,
            }
        }
    };
    let key_bytes = match hex::decode(&key_hex) {
        Ok(b) => b,
        Err(_) => {
            return AuditVerifyResult {
                valid: false,
                reason: Some("signature_invalid".into()),
                payload_hash: recomputed,
                key_source,
            }
        }
    };

    if ed25519_verify(&canonical, &sig_bytes, &key_bytes) {
        AuditVerifyResult {
            valid: true,
            reason: None,
            payload_hash: recomputed,
            key_source,
        }
    } else {
        AuditVerifyResult {
            valid: false,
            reason: Some("signature_invalid".into()),
            payload_hash: recomputed,
            key_source,
        }
    }
}