holographic_memory/core/provenance/
scitt.rs1use anyhow::{anyhow, Result};
5use coset::{CborSerializable, CoseSign1, CoseSign1Builder, HeaderBuilder};
6use ed25519_dalek::{Signer, SigningKey, Verifier};
7use serde::{Deserialize, Serialize};
8
9const SCITT_CONTENT_TYPE: &str = "application/cbor";
10
11#[derive(Clone, Debug)]
14pub struct SignedStatement {
15 pub cose_bytes: Vec<u8>,
16}
17
18#[derive(Clone, Debug, Serialize, Deserialize)]
20pub struct TransparencyReceipt {
21 pub entry_id: String,
22 pub receipt_bytes: Vec<u8>,
23 pub log_id: Option<String>,
24}
25
26#[derive(Clone, Debug, Serialize, Deserialize)]
28pub struct FactClaim {
29 pub fact_id: String,
30 pub content_hash: Vec<u8>,
31 pub timestamp_ms: u64,
32 pub source_uri: Option<String>,
33 pub store_id: Option<String>,
34}
35
36pub fn create_signed_statement(
38 signing_key: &SigningKey,
39 claim: &FactClaim,
40) -> Result<SignedStatement> {
41 let mut payload = Vec::new();
42 let cbor_claim = ciborium::Value::Map(vec![
43 (
44 ciborium::Value::Text("fact_id".to_string()),
45 ciborium::Value::Text(claim.fact_id.clone()),
46 ),
47 (
48 ciborium::Value::Text("content_hash".to_string()),
49 ciborium::Value::Bytes(claim.content_hash.clone()),
50 ),
51 (
52 ciborium::Value::Text("timestamp_ms".to_string()),
53 ciborium::Value::Integer(claim.timestamp_ms.into()),
54 ),
55 (
56 ciborium::Value::Text("source_uri".to_string()),
57 match &claim.source_uri {
58 Some(uri) => ciborium::Value::Text(uri.clone()),
59 None => ciborium::Value::Null,
60 },
61 ),
62 (
63 ciborium::Value::Text("store_id".to_string()),
64 match &claim.store_id {
65 Some(id) => ciborium::Value::Text(id.clone()),
66 None => ciborium::Value::Null,
67 },
68 ),
69 ]);
70 ciborium::into_writer(&cbor_claim, &mut payload)
71 .map_err(|e| anyhow!("CBOR encoding failed: {e}"))?;
72
73 let protected = HeaderBuilder::new()
74 .algorithm(coset::iana::Algorithm::EdDSA)
75 .key_id(signing_key.verifying_key().to_bytes().to_vec())
76 .content_type(SCITT_CONTENT_TYPE.to_string())
77 .build();
78
79 let sign1 = CoseSign1Builder::new()
80 .protected(protected)
81 .payload(payload)
82 .create_signature(b"", |data| signing_key.sign(data).to_bytes().to_vec())
83 .build();
84
85 let cose_bytes = sign1
86 .to_vec()
87 .map_err(|e| anyhow!("COSE serialization failed: {e}"))?;
88
89 Ok(SignedStatement { cose_bytes })
90}
91
92#[cfg(feature = "provenance-scitt")]
94pub fn submit_statement(
95 endpoint: &str,
96 statement: &SignedStatement,
97) -> Result<TransparencyReceipt> {
98 let url = format!("{}/entries", endpoint.trim_end_matches('/'));
99
100 let response = ureq::post(&url)
101 .header("Content-Type", SCITT_CONTENT_TYPE)
102 .send(&statement.cose_bytes)
103 .map_err(|e| anyhow!("SCITT submission failed: {e}"))?;
104
105 let status = response.status().as_u16();
106 if status != 201 && status != 200 {
107 let body = response.into_body().read_to_string().unwrap_or_default();
108 return Err(anyhow!("SCITT service returned {status}: {body}"));
109 }
110
111 if status == 201 {
112 let location = response
113 .headers()
114 .get("Location")
115 .and_then(|v| v.to_str().ok())
116 .unwrap_or("")
117 .to_string();
118 let operation_url = if location.starts_with("http") {
119 location
120 } else {
121 format!("{}{}", endpoint.trim_end_matches('/'), location)
122 };
123 return poll_for_receipt(endpoint, &operation_url);
124 }
125
126 let receipt_bytes = response
127 .into_body()
128 .read_to_vec()
129 .map_err(|e| anyhow!("failed to read receipt: {e}"))?;
130
131 Ok(TransparencyReceipt {
132 entry_id: String::new(),
133 receipt_bytes,
134 log_id: None,
135 })
136}
137
138#[cfg(feature = "provenance-scitt")]
139fn poll_for_receipt(endpoint: &str, operation_url: &str) -> Result<TransparencyReceipt> {
140 for _ in 0..30 {
141 std::thread::sleep(std::time::Duration::from_secs(1));
142
143 let resp = ureq::get(operation_url)
144 .call()
145 .map_err(|e| anyhow!("polling failed: {e}"))?;
146
147 if resp.status().as_u16() == 200 {
148 let body_str = resp
149 .into_body()
150 .read_to_string()
151 .map_err(|e| anyhow!("response read failed: {e}"))?;
152 let body: serde_json::Value =
153 serde_json::from_str(&body_str).map_err(|e| anyhow!("JSON parse failed: {e}"))?;
154
155 if body.get("status").and_then(|s| s.as_str()) == Some("succeeded") {
156 let entry_id = body
157 .get("entryId")
158 .and_then(|v| v.as_str())
159 .unwrap_or("")
160 .to_string();
161
162 let receipt_url = format!(
163 "{}/entries/{}/receipt",
164 endpoint.trim_end_matches('/'),
165 entry_id
166 );
167 let receipt_resp = ureq::get(&receipt_url)
168 .call()
169 .map_err(|e| anyhow!("receipt fetch failed: {e}"))?;
170 let receipt_bytes = receipt_resp
171 .into_body()
172 .read_to_vec()
173 .map_err(|e| anyhow!("receipt read failed: {e}"))?;
174
175 return Ok(TransparencyReceipt {
176 entry_id,
177 receipt_bytes,
178 log_id: body.get("logId").and_then(|v| v.as_str()).map(String::from),
179 });
180 }
181 }
182 }
183 Err(anyhow!("SCITT receipt polling timed out after 30 seconds"))
184}
185
186pub fn verify_statement(
188 verifying_key: &ed25519_dalek::VerifyingKey,
189 statement: &SignedStatement,
190) -> Result<FactClaim> {
191 let sign1 = CoseSign1::from_slice(&statement.cose_bytes)
192 .map_err(|e| anyhow!("COSE deserialization failed: {e}"))?;
193
194 sign1
195 .verify_signature(b"", |sig, data| {
196 let signature = ed25519_dalek::Signature::from_slice(sig)
197 .map_err(|e| anyhow!("invalid signature: {e}"))?;
198 verifying_key
199 .verify(data, &signature)
200 .map_err(|e| anyhow!("verification failed: {e}"))
201 })
202 .map_err(|e| anyhow!("SCITT statement verification failed: {e}"))?;
203
204 let payload = sign1
205 .payload
206 .as_ref()
207 .ok_or_else(|| anyhow!("no payload in statement"))?;
208
209 let decoded: ciborium::Value = ciborium::from_reader(payload.as_slice())
210 .map_err(|e| anyhow!("CBOR decode failed: {e}"))?;
211
212 parse_fact_claim(&decoded)
213}
214
215fn parse_fact_claim(val: &ciborium::Value) -> Result<FactClaim> {
216 let map = match val {
217 ciborium::Value::Map(m) => m,
218 _ => return Err(anyhow!("expected CBOR map")),
219 };
220
221 let get_text = |key: &str| -> Option<String> {
222 map.iter()
223 .find(|(k, _)| k == &ciborium::Value::Text(key.to_string()))
224 .and_then(|(_, v)| match v {
225 ciborium::Value::Text(s) => Some(s.clone()),
226 _ => None,
227 })
228 };
229
230 let get_bytes = |key: &str| -> Option<Vec<u8>> {
231 map.iter()
232 .find(|(k, _)| k == &ciborium::Value::Text(key.to_string()))
233 .and_then(|(_, v)| match v {
234 ciborium::Value::Bytes(b) => Some(b.clone()),
235 _ => None,
236 })
237 };
238
239 let get_int = |key: &str| -> Option<u64> {
240 map.iter()
241 .find(|(k, _)| k == &ciborium::Value::Text(key.to_string()))
242 .and_then(|(_, v)| match v {
243 ciborium::Value::Integer(i) => u64::try_from(*i).ok(),
244 _ => None,
245 })
246 };
247
248 Ok(FactClaim {
249 fact_id: get_text("fact_id").ok_or_else(|| anyhow!("missing fact_id"))?,
250 content_hash: get_bytes("content_hash").ok_or_else(|| anyhow!("missing content_hash"))?,
251 timestamp_ms: get_int("timestamp_ms").ok_or_else(|| anyhow!("missing timestamp_ms"))?,
252 source_uri: get_text("source_uri"),
253 store_id: get_text("store_id"),
254 })
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 fn test_keypair() -> SigningKey {
262 SigningKey::from_bytes(&rand::random())
263 }
264
265 #[test]
266 fn signed_statement_roundtrip() {
267 let key = test_keypair();
268 let claim = FactClaim {
269 fact_id: "fact-001".to_string(),
270 content_hash: vec![0xAB; 32],
271 timestamp_ms: 1719878400000,
272 source_uri: Some("https://example.com".to_string()),
273 store_id: Some("store-1".to_string()),
274 };
275
276 let statement = create_signed_statement(&key, &claim).unwrap();
277 let recovered = verify_statement(&key.verifying_key(), &statement).unwrap();
278
279 assert_eq!(recovered.fact_id, "fact-001");
280 assert_eq!(recovered.content_hash, vec![0xAB; 32]);
281 assert_eq!(recovered.timestamp_ms, 1719878400000);
282 assert_eq!(recovered.source_uri.as_deref(), Some("https://example.com"));
283 }
284
285 #[test]
286 fn wrong_key_rejected() {
287 let key = test_keypair();
288 let claim = FactClaim {
289 fact_id: "f1".to_string(),
290 content_hash: vec![0; 32],
291 timestamp_ms: 0,
292 source_uri: None,
293 store_id: None,
294 };
295
296 let statement = create_signed_statement(&key, &claim).unwrap();
297 let wrong_key = test_keypair();
298 assert!(verify_statement(&wrong_key.verifying_key(), &statement).is_err());
299 }
300}