invoance 0.1.0

Official Rust SDK for the Invoance compliance API
Documentation
//! AI Attestations resource — `client.attestations()`.

use std::sync::Arc;

use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};

use crate::error::Error;
use crate::http::{HttpTransport, QueryParams};
use crate::models::attestations::*;
use crate::validate::assert_sha256_hex;

/// Input for [`AttestationsResource::verify_payload`].
///
/// Prefer [`PayloadInput::Str`]: it compacts the raw JSON while **preserving
/// key order**, matching the server's `serde_json` struct-field hashing. A
/// [`PayloadInput::Value`] is compacted via `serde_json`, whose default
/// (non-`preserve_order`) build alphabetizes object keys — safe only when the
/// stored payload's objects already happen to be in alphabetical order.
pub enum PayloadInput<'a> {
    /// Raw JSON text (the "Raw immutable record" exactly as shown).
    Str(&'a str),
    /// A parsed JSON value.
    Value(Value),
}

impl<'a> From<&'a str> for PayloadInput<'a> {
    fn from(s: &'a str) -> Self {
        PayloadInput::Str(s)
    }
}
impl From<Value> for PayloadInput<'_> {
    fn from(v: Value) -> Self {
        PayloadInput::Value(v)
    }
}

/// Handle for the `/ai/attestations` endpoints.
#[derive(Clone)]
pub struct AttestationsResource {
    t: Arc<HttpTransport>,
}

impl AttestationsResource {
    pub(crate) fn new(t: Arc<HttpTransport>) -> Self {
        Self { t }
    }

    /// `POST /ai/attestations` — anchor an AI attestation.
    ///
    /// Body shape (nested — field order matters for server hashing):
    /// `{ type, payload: { input, output }, context: { model_provider,
    /// model_name, model_version }, subject?, trace_id? }`.
    pub async fn ingest(
        &self,
        params: IngestAttestationParams,
    ) -> Result<IngestAttestationResponse, Error> {
        let mut body = Map::new();
        body.insert("type".into(), json!(params.r#type));
        body.insert(
            "payload".into(),
            json!({ "input": params.input, "output": params.output }),
        );
        body.insert(
            "context".into(),
            json!({
                "model_provider": params.model_provider,
                "model_name": params.model_name,
                "model_version": params.model_version,
            }),
        );

        if let Some(sub) = params.subject {
            let mut subject = Map::new();
            // Map user_id / session_id first, then pass through extras. Note the
            // caller supplies wire keys already (`user_id`, `session_id`).
            if let Some(v) = sub.get("user_id").filter(|v| !v.is_null()) {
                subject.insert("user_id".into(), v.clone());
            }
            if let Some(v) = sub.get("session_id").filter(|v| !v.is_null()) {
                subject.insert("session_id".into(), v.clone());
            }
            for (k, v) in &sub {
                if k != "user_id" && k != "session_id" {
                    subject.insert(k.clone(), v.clone());
                }
            }
            if !subject.is_empty() {
                body.insert("subject".into(), Value::Object(subject));
            }
        }

        if let Some(t) = &params.trace_id {
            body.insert("trace_id".into(), json!(t));
        }

        self.t
            .post(
                "/ai/attestations",
                Some(&Value::Object(body)),
                params.idempotency_key.as_deref(),
            )
            .await
    }

    /// `GET /ai/attestations` — paginated attestation listing.
    pub async fn list(
        &self,
        params: ListAttestationsParams,
    ) -> Result<ListAttestationsResponse, Error> {
        let q: QueryParams = vec![
            ("page", params.page.map(|v| v.to_string())),
            ("limit", params.limit.map(|v| v.to_string())),
            ("date_from", params.date_from),
            ("date_to", params.date_to),
            ("attestation_type", params.attestation_type),
            ("model_provider", params.model_provider),
        ];
        self.t.get("/ai/attestations", Some(q)).await
    }

    /// `GET /ai/attestations/{id}` — retrieve a single attestation.
    pub async fn get(&self, attestation_id: &str) -> Result<AiAttestation, Error> {
        self.t
            .get(&format!("/ai/attestations/{attestation_id}"), None)
            .await
    }

    /// `POST /ai/attestations/{id}/verify` — hash verification.
    pub async fn verify(
        &self,
        attestation_id: &str,
        params: VerifyAttestationParams,
    ) -> Result<VerifyAttestationResponse, Error> {
        assert_sha256_hex("content_hash", &params.content_hash)?;
        let body = json!({ "content_hash": params.content_hash });
        self.t
            .post(
                &format!("/ai/attestations/{attestation_id}/verify"),
                Some(&body),
                None,
            )
            .await
    }

    /// `GET /ai/attestations/{id}/raw` — the original canonical JSON payload.
    pub async fn get_raw(&self, attestation_id: &str) -> Result<Value, Error> {
        self.t
            .get_raw(&format!("/ai/attestations/{attestation_id}/raw"))
            .await
    }

    /// Verify by raw payload — hashes client-side, then calls
    /// [`verify`](Self::verify).
    ///
    /// Pass the canonical JSON stored in Invoance (the "Raw immutable record")
    /// as a [`PayloadInput::Str`] to preserve key order. See [`PayloadInput`].
    pub async fn verify_payload<'a>(
        &self,
        attestation_id: &str,
        payload: impl Into<PayloadInput<'a>>,
    ) -> Result<VerifyAttestationResponse, Error> {
        let canonical = match payload.into() {
            // Compact the raw string in place, preserving source key order.
            PayloadInput::Str(s) => compact_json_preserving_order(s)?,
            // For a parsed value we can only emit via serde_json (alphabetized
            // under default features). Documented in `PayloadInput`.
            PayloadInput::Value(v) => serde_json::to_string(&v)
                .map_err(|e| Error::validation(format!("failed to serialize payload: {e}")))?,
        };

        let mut hasher = Sha256::new();
        hasher.update(canonical.as_bytes());
        let content_hash = hex::encode(hasher.finalize());

        self.verify(attestation_id, VerifyAttestationParams { content_hash })
            .await
    }

    /// Verify the Ed25519 signature of an attestation — fully client-side.
    ///
    /// Fetches the attestation, then verifies the signature over the
    /// hex-decoded `signed_payload` using the hex-decoded raw `public_key`.
    pub async fn verify_signature(
        &self,
        attestation_id: &str,
    ) -> Result<SignatureVerificationResult, Error> {
        use ed25519_dalek::{Signature, VerifyingKey};

        let att = self.get(attestation_id).await?;

        let signed_payload_bytes = hex::decode(&att.signed_payload).ok();
        let signature_bytes = hex::decode(&att.signature).ok();
        let public_key_bytes = hex::decode(&att.public_key).ok();

        let mut valid = false;
        let mut reason: Option<String> = None;

        match (&signed_payload_bytes, &signature_bytes, &public_key_bytes) {
            (Some(msg), Some(sig), Some(pk)) => {
                let key_res: Result<[u8; 32], _> = pk.as_slice().try_into();
                match key_res {
                    Ok(kb) => match VerifyingKey::from_bytes(&kb) {
                        Ok(vk) => match Signature::from_slice(sig) {
                            Ok(s) => {
                                if vk.verify_strict(msg, &s).is_ok() {
                                    valid = true;
                                } else {
                                    reason = Some(
                                        "Signature does not match signed_payload + public_key"
                                            .into(),
                                    );
                                }
                            }
                            Err(e) => reason = Some(format!("invalid signature bytes: {e}")),
                        },
                        Err(e) => reason = Some(format!("invalid public key: {e}")),
                    },
                    Err(_) => reason = Some("public key must be 32 bytes".into()),
                }
            }
            _ => reason = Some("signed_payload, signature, or public_key was not valid hex".into()),
        }

        let signed_data = signed_payload_bytes
            .as_ref()
            .and_then(|b| serde_json::from_slice::<Value>(b).ok());

        Ok(SignatureVerificationResult {
            valid,
            reason,
            attestation: att,
            signed_data,
        })
    }
}

/// Compact a JSON string by removing insignificant whitespace, leaving string
/// literals (and their contents) untouched. This reproduces
/// `JSON.stringify(JSON.parse(s))` semantics — compact, preserve key order —
/// without reparsing into a map that would reorder keys.
fn compact_json_preserving_order(s: &str) -> Result<String, Error> {
    let mut out = String::with_capacity(s.len());
    let mut in_string = false;
    let mut escaped = false;
    let mut saw_content = false;

    for ch in s.chars() {
        if in_string {
            out.push(ch);
            if escaped {
                escaped = false;
            } else if ch == '\\' {
                escaped = true;
            } else if ch == '"' {
                in_string = false;
            }
            continue;
        }
        match ch {
            '"' => {
                in_string = true;
                out.push(ch);
                saw_content = true;
            }
            ' ' | '\t' | '\n' | '\r' => {
                // insignificant whitespace outside a string — drop it
            }
            other => {
                out.push(other);
                saw_content = true;
            }
        }
    }

    if !saw_content {
        return Err(Error::validation("payload is not valid JSON (empty)"));
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::compact_json_preserving_order;

    #[test]
    fn compacts_and_preserves_key_order() {
        // Pretty-printed, keys in struct order (type, payload, context, subject).
        let raw = r#"{
            "type": "output",
            "payload": { "input": "hi", "output": "there" },
            "context": { "model_provider": "openai", "model_name": "gpt-4o" }
        }"#;
        let out = compact_json_preserving_order(raw).unwrap();
        assert_eq!(
            out,
            r#"{"type":"output","payload":{"input":"hi","output":"there"},"context":{"model_provider":"openai","model_name":"gpt-4o"}}"#
        );
    }

    #[test]
    fn leaves_whitespace_inside_strings_intact() {
        let raw = r#"{ "k": "a b\t c" }"#;
        assert_eq!(
            compact_json_preserving_order(raw).unwrap(),
            r#"{"k":"a b\t c"}"#
        );
    }

    #[test]
    fn keeps_escaped_quote_in_string() {
        let raw = r#"{ "k": "he said \"hi\"" }"#;
        assert_eq!(
            compact_json_preserving_order(raw).unwrap(),
            r#"{"k":"he said \"hi\""}"#
        );
    }
}