Skip to main content

holographic_memory/core/provenance/
scitt.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use 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/// A SCITT signed statement: a COSE Sign1 envelope with SCITT-specific headers.
12/// Per draft-ietf-scitt-architecture, the payload is the claim (fact hash + metadata).
13#[derive(Clone, Debug)]
14pub struct SignedStatement {
15    pub cose_bytes: Vec<u8>,
16}
17
18/// A SCITT transparency receipt returned by the Transparency Service.
19#[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/// SCITT claim payload for a knowledge insertion.
27#[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
36/// Build a SCITT signed statement for a fact insertion.
37pub 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/// Submit a signed statement to a SCITT Transparency Service and get a receipt.
93#[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
186/// Verify a SCITT signed statement's COSE signature (local verification, no TS needed).
187pub 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}