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;
pub enum PayloadInput<'a> {
Str(&'a str),
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)
}
}
#[derive(Clone)]
pub struct AttestationsResource {
t: Arc<HttpTransport>,
}
impl AttestationsResource {
pub(crate) fn new(t: Arc<HttpTransport>) -> Self {
Self { t }
}
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();
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) = ¶ms.trace_id {
body.insert("trace_id".into(), json!(t));
}
self.t
.post(
"/ai/attestations",
Some(&Value::Object(body)),
params.idempotency_key.as_deref(),
)
.await
}
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
}
pub async fn get(&self, attestation_id: &str) -> Result<AiAttestation, Error> {
self.t
.get(&format!("/ai/attestations/{attestation_id}"), None)
.await
}
pub async fn verify(
&self,
attestation_id: &str,
params: VerifyAttestationParams,
) -> Result<VerifyAttestationResponse, Error> {
assert_sha256_hex("content_hash", ¶ms.content_hash)?;
let body = json!({ "content_hash": params.content_hash });
self.t
.post(
&format!("/ai/attestations/{attestation_id}/verify"),
Some(&body),
None,
)
.await
}
pub async fn get_raw(&self, attestation_id: &str) -> Result<Value, Error> {
self.t
.get_raw(&format!("/ai/attestations/{attestation_id}/raw"))
.await
}
pub async fn verify_payload<'a>(
&self,
attestation_id: &str,
payload: impl Into<PayloadInput<'a>>,
) -> Result<VerifyAttestationResponse, Error> {
let canonical = match payload.into() {
PayloadInput::Str(s) => compact_json_preserving_order(s)?,
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
}
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,
})
}
}
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' => {
}
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() {
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\""}"#
);
}
}