use std::collections::BTreeMap;
use base64::Engine;
use serde::{Deserialize, Serialize};
pub const PROTOCOL_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PinHeader {
pub v: u32,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub model_hash: Option<String>,
pub source_hash: String,
pub vec_hash: String,
pub vec_dtype: String,
pub vec_dim: u32,
pub ts: String,
#[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
pub extra: BTreeMap<String, String>,
}
impl PinHeader {
pub fn canonicalize(&self) -> Vec<u8> {
let mut entries: Vec<(&'static str, serde_json::Value)> = Vec::new();
entries.push(("v", serde_json::Value::Number(self.v.into())));
entries.push(("model", serde_json::Value::String(self.model.clone())));
if let Some(h) = &self.model_hash {
entries.push(("model_hash", serde_json::Value::String(h.clone())));
}
entries.push((
"source_hash",
serde_json::Value::String(self.source_hash.clone()),
));
entries.push(("vec_hash", serde_json::Value::String(self.vec_hash.clone())));
entries.push((
"vec_dtype",
serde_json::Value::String(self.vec_dtype.clone()),
));
entries.push(("vec_dim", serde_json::Value::Number(self.vec_dim.into())));
entries.push(("ts", serde_json::Value::String(self.ts.clone())));
if !self.extra.is_empty() {
let mut m = serde_json::Map::new();
for (k, val) in &self.extra {
m.insert(k.clone(), serde_json::Value::String(val.clone()));
}
entries.push(("extra", serde_json::Value::Object(m)));
}
entries.sort_by(|a, b| a.0.cmp(b.0));
let mut map = serde_json::Map::with_capacity(entries.len());
for (k, v) in entries {
map.insert(k.to_string(), v);
}
serde_json::to_vec(&serde_json::Value::Object(map))
.expect("JSON serialization of well-formed map cannot fail")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pin {
pub header: PinHeader,
pub kid: String,
pub sig: Vec<u8>,
}
pub(crate) fn b64url_encode(data: &[u8]) -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
}
pub(crate) fn b64url_decode(s: &str) -> Result<Vec<u8>, AttestationError> {
base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(s.as_bytes())
.map_err(AttestationError::Base64)
}
#[derive(Debug, thiserror::Error)]
pub enum AttestationError {
#[error("unsupported pin version: got {got}, expected {expected}")]
UnsupportedVersion {
got: u32,
expected: u32,
},
#[error("malformed pin JSON: {0}")]
Json(#[from] serde_json::Error),
#[error("malformed base64: {0}")]
Base64(#[from] base64::DecodeError),
#[error("missing required field: {0}")]
MissingField(&'static str),
}
impl Pin {
pub fn to_json(&self) -> String {
let mut entries: Vec<(&str, serde_json::Value)> = Vec::new();
entries.push(("v", serde_json::Value::Number(self.header.v.into())));
entries.push((
"model",
serde_json::Value::String(self.header.model.clone()),
));
if let Some(h) = &self.header.model_hash {
entries.push(("model_hash", serde_json::Value::String(h.clone())));
}
entries.push((
"source_hash",
serde_json::Value::String(self.header.source_hash.clone()),
));
entries.push((
"vec_hash",
serde_json::Value::String(self.header.vec_hash.clone()),
));
entries.push((
"vec_dtype",
serde_json::Value::String(self.header.vec_dtype.clone()),
));
entries.push((
"vec_dim",
serde_json::Value::Number(self.header.vec_dim.into()),
));
entries.push(("ts", serde_json::Value::String(self.header.ts.clone())));
if !self.header.extra.is_empty() {
let mut m = serde_json::Map::new();
for (k, val) in &self.header.extra {
m.insert(k.clone(), serde_json::Value::String(val.clone()));
}
entries.push(("extra", serde_json::Value::Object(m)));
}
entries.push(("kid", serde_json::Value::String(self.kid.clone())));
entries.push(("sig", serde_json::Value::String(b64url_encode(&self.sig))));
entries.sort_by(|a, b| a.0.cmp(b.0));
let mut map = serde_json::Map::with_capacity(entries.len());
for (k, v) in entries {
map.insert(k.to_string(), v);
}
serde_json::to_string(&serde_json::Value::Object(map))
.expect("JSON serialization of well-formed map cannot fail")
}
pub fn from_json(s: &str) -> Result<Self, AttestationError> {
let value: serde_json::Value = serde_json::from_str(s)?;
Self::from_value(value)
}
pub fn from_value(value: serde_json::Value) -> Result<Self, AttestationError> {
let obj = value
.as_object()
.ok_or(AttestationError::MissingField("(root)"))?;
let v = obj
.get("v")
.and_then(|x| x.as_u64())
.ok_or(AttestationError::MissingField("v"))? as u32;
if v != PROTOCOL_VERSION {
return Err(AttestationError::UnsupportedVersion {
got: v,
expected: PROTOCOL_VERSION,
});
}
fn s_field(
obj: &serde_json::Map<String, serde_json::Value>,
name: &'static str,
) -> Result<String, AttestationError> {
obj.get(name)
.and_then(|x| x.as_str())
.map(str::to_owned)
.ok_or(AttestationError::MissingField(name))
}
let header = PinHeader {
v,
model: s_field(obj, "model")?,
model_hash: obj
.get("model_hash")
.and_then(|x| x.as_str())
.map(String::from),
source_hash: s_field(obj, "source_hash")?,
vec_hash: s_field(obj, "vec_hash")?,
vec_dtype: s_field(obj, "vec_dtype")?,
vec_dim: obj
.get("vec_dim")
.and_then(|x| x.as_u64())
.ok_or(AttestationError::MissingField("vec_dim"))? as u32,
ts: s_field(obj, "ts")?,
extra: obj
.get("extra")
.and_then(|x| x.as_object())
.map(|m| {
m.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_owned())))
.collect()
})
.unwrap_or_default(),
};
let kid = s_field(obj, "kid")?;
let sig = b64url_decode(s_field(obj, "sig")?.as_str())?;
Ok(Pin { header, kid, sig })
}
}
#[cfg(test)]
mod tests {
use super::*;
fn header() -> PinHeader {
PinHeader {
v: PROTOCOL_VERSION,
model: "test-model".into(),
model_hash: None,
source_hash: format!("sha256:{}", "0".repeat(64)),
vec_hash: format!("sha256:{}", "1".repeat(64)),
vec_dtype: "f32".into(),
vec_dim: 3072,
ts: "2026-05-05T12:00:00Z".into(),
extra: BTreeMap::new(),
}
}
#[test]
fn canonicalize_is_deterministic() {
let h = header();
assert_eq!(h.canonicalize(), h.canonicalize());
}
#[test]
fn canonicalize_omits_optional_when_unset() {
let raw = String::from_utf8(header().canonicalize()).unwrap();
assert!(!raw.contains("model_hash"));
assert!(!raw.contains("extra"));
}
#[test]
fn pin_round_trip_via_json() {
let pin = Pin {
header: header(),
kid: "k".into(),
sig: vec![1u8; 64],
};
let restored = Pin::from_json(&pin.to_json()).unwrap();
assert_eq!(pin, restored);
}
#[test]
fn pin_rejects_unsupported_version() {
let bad = serde_json::json!({
"v": 99,
"model": "x",
"source_hash": format!("sha256:{}", "0".repeat(64)),
"vec_hash": format!("sha256:{}", "1".repeat(64)),
"vec_dtype": "f32",
"vec_dim": 1,
"ts": "2026-05-05T12:00:00Z",
"kid": "k",
"sig": "AA",
});
let err = Pin::from_value(bad).unwrap_err();
assert!(matches!(err, AttestationError::UnsupportedVersion { .. }));
}
#[test]
fn pin_to_json_is_compact() {
let pin = Pin {
header: header(),
kid: "k".into(),
sig: vec![1u8; 64],
};
let j = pin.to_json();
assert!(!j.contains(": "));
assert!(!j.contains(", "));
}
}