use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::attestation::{sign, verify_with_key, Envelope, SignResult, Signer};
use crate::statements::{payload_type, ActionStatement, TYPE_ACTION};
use super::jws::b64u;
use super::ViError;
pub const ATTESTATION_SCHEME: &str = "treeship.receipt-chain.v1";
pub const ATTESTATION_ACTION: &str = "vi.l3.attested";
pub const ATTESTATION_STATEMENT_TYPE: &str = "treeship/vi-attestation/v1";
pub fn attestation_payload_type() -> String {
payload_type("action")
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AttestationStatement {
#[serde(rename = "type")]
pub type_: String,
pub timestamp: String,
pub actor: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session: Option<String>,
pub chain_head: String,
pub checkpoint: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approval_use: Option<String>,
pub mandate_digest: String,
pub transaction_id: String,
pub chain_length: u64,
}
impl AttestationStatement {
pub fn new(
actor: &str,
session: Option<String>,
chain_head: &str,
checkpoint: &str,
chain_length: u64,
approval_use: Option<String>,
mandate_digest: &str,
transaction_id: &str,
timestamp: &str,
) -> Self {
Self {
type_: ATTESTATION_STATEMENT_TYPE.into(),
timestamp: timestamp.into(),
actor: actor.into(),
session,
chain_head: chain_head.into(),
checkpoint: checkpoint.into(),
approval_use,
mandate_digest: mandate_digest.into(),
transaction_id: transaction_id.into(),
chain_length,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttestationClaim {
#[serde(rename = "type")]
pub type_: String,
pub value: AttestationValue,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttestationValue {
pub artifact_id: String,
pub key_id: String,
pub public_key: String,
pub envelope: Envelope,
}
pub fn build_attestation_claim(
stmt: &AttestationStatement,
signer: &dyn Signer,
) -> Result<(AttestationClaim, SignResult), ViError> {
let mut action = ActionStatement::new(&stmt.actor, ATTESTATION_ACTION);
action.timestamp = stmt.timestamp.clone();
action.parent_id = Some(stmt.chain_head.clone());
action.meta = Some(serde_json::to_value(stmt).map_err(|e| ViError::Malformed(e.to_string()))?);
let pt = attestation_payload_type();
let result = sign(&pt, &action, signer).map_err(|e| ViError::Signature(e.to_string()))?;
let claim = AttestationClaim {
type_: ATTESTATION_SCHEME.into(),
value: AttestationValue {
artifact_id: result.artifact_id.clone(),
key_id: signer.key_id().to_string(),
public_key: format!("ed25519:{}", b64u(&signer.public_key_bytes())),
envelope: result.envelope.clone(),
},
};
Ok((claim, result))
}
#[derive(Debug, Clone)]
pub struct AttestationVerified {
pub artifact_id: String,
pub key_id: String,
pub public_key: String,
pub statement: AttestationStatement,
}
pub fn verify_attestation_claim(claim: &Value) -> Result<AttestationVerified, ViError> {
let parsed: AttestationClaim = serde_json::from_value(claim.clone())
.map_err(|e| ViError::Malformed(format!("agent_attestation: {e}")))?;
if parsed.type_ != ATTESTATION_SCHEME {
return Err(ViError::Malformed(format!(
"agent_attestation type is '{}', not '{ATTESTATION_SCHEME}'",
parsed.type_
)));
}
let v = &parsed.value;
let pk_b64 = v
.public_key
.strip_prefix("ed25519:")
.ok_or_else(|| ViError::Malformed("public_key must be 'ed25519:<base64url>'".into()))?;
let pk = super::jws::b64u_decode(pk_b64)?;
let pk: [u8; 32] = pk
.as_slice()
.try_into()
.map_err(|_| ViError::Key("ed25519 public key must be 32 bytes".into()))?;
let vk = ed25519_dalek::VerifyingKey::from_bytes(&pk)
.map_err(|e| ViError::Key(format!("ed25519 public key: {e}")))?;
if v.envelope.payload_type != attestation_payload_type() {
return Err(ViError::Malformed(format!(
"attestation envelope payloadType is '{}'",
v.envelope.payload_type
)));
}
let res = verify_with_key(&v.envelope, &v.key_id, vk)
.map_err(|e| ViError::Signature(format!("attestation envelope: {e}")))?;
if res.artifact_id != v.artifact_id {
return Err(ViError::Signature(format!(
"attestation artifact id {} does not match the envelope ({})",
v.artifact_id, res.artifact_id
)));
}
let payload = super::jws::b64u_decode(&v.envelope.payload)?;
let action: Value = serde_json::from_slice(&payload)
.map_err(|e| ViError::Malformed(format!("attestation statement: {e}")))?;
if action.get("type").and_then(Value::as_str) != Some(TYPE_ACTION) {
return Err(ViError::Malformed(format!(
"attestation statement type is {}",
action.get("type").cloned().unwrap_or(Value::Null)
)));
}
if action.get("action").and_then(Value::as_str) != Some(ATTESTATION_ACTION) {
return Err(ViError::Malformed(format!(
"attestation action is {}",
action.get("action").cloned().unwrap_or(Value::Null)
)));
}
let statement: AttestationStatement =
serde_json::from_value(action.get("meta").cloned().unwrap_or(Value::Null))
.map_err(|e| ViError::Malformed(format!("attestation meta: {e}")))?;
if statement.type_ != ATTESTATION_STATEMENT_TYPE {
return Err(ViError::Malformed(format!(
"attestation meta type is '{}'",
statement.type_
)));
}
if action.get("parentId").and_then(Value::as_str) != Some(statement.chain_head.as_str()) {
return Err(ViError::Signature(
"attestation parentId is not the chain head it names".into(),
));
}
if action.get("actor").and_then(Value::as_str) != Some(statement.actor.as_str()) {
return Err(ViError::Signature(
"attestation actor differs between the action and its meta".into(),
));
}
Ok(AttestationVerified {
artifact_id: v.artifact_id.clone(),
key_id: v.key_id.clone(),
public_key: v.public_key.clone(),
statement,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::attestation::Ed25519Signer;
fn stmt() -> AttestationStatement {
AttestationStatement::new(
"agent://shopping",
Some("ssn_x".into()),
"art_head",
"mroot_00",
13,
Some("sha256:aa".into()),
"MDIG",
"TXID",
"2026-09-08T00:00:00Z",
)
}
#[test]
fn claim_round_trips_and_tamper_fails() {
let signer = Ed25519Signer::generate("key_test").unwrap();
let (claim, _) = build_attestation_claim(&stmt(), &signer).unwrap();
let v = serde_json::to_value(&claim).unwrap();
let ok = verify_attestation_claim(&v).unwrap();
assert_eq!(ok.statement.chain_head, "art_head");
assert_eq!(ok.key_id, "key_test");
let mut bad = v.clone();
let p = bad["value"]["envelope"]["payload"]
.as_str()
.unwrap()
.to_string();
let mut bytes = super::super::jws::b64u_decode(&p).unwrap();
let i = bytes.iter().position(|b| *b == b'h').unwrap();
bytes[i] = b'H';
bad["value"]["envelope"]["payload"] = Value::String(b64u(&bytes));
assert!(verify_attestation_claim(&bad).is_err());
let other = Ed25519Signer::generate("key_other").unwrap();
let (claim2, _) = build_attestation_claim(&stmt(), &other).unwrap();
assert_ne!(claim2.value.public_key, claim.value.public_key);
let mut swapped = v.clone();
swapped["value"]["public_key"] = Value::String(claim2.value.public_key.clone());
assert!(verify_attestation_claim(&swapped).is_err());
}
}