fleetforge_trust/
c2pa.rs

1use std::collections::BTreeMap;
2
3use anyhow::{anyhow, Context, Result};
4use chrono::{DateTime, SecondsFormat, Utc};
5use serde::{Deserialize, Serialize};
6use serde_json::{json, Map, Value};
7use uuid::Uuid;
8
9use super::{
10    c2pa_signer, digest_bytes, verify_signature_envelope, CapabilityToken, CapabilityTokenScope,
11    SignatureEnvelope,
12};
13
14/// Canonical C2PA envelope containing the manifest payload and detached signature.
15#[derive(Clone, Debug, Serialize, Deserialize)]
16pub struct C2paManifestEnvelope {
17    pub manifest: Value,
18    pub signature: SignatureEnvelope,
19}
20
21pub struct VerifiedManifest {
22    pub manifest: Value,
23    pub attestation_ids: Vec<Uuid>,
24    pub artifact_sha256: String,
25}
26
27#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
28#[serde(rename_all = "kebab-case")]
29pub enum ManifestProfile {
30    Basic,
31    PolicyEvidence,
32    Full,
33}
34
35impl ManifestProfile {
36    pub fn as_str(&self) -> &'static str {
37        match self {
38            ManifestProfile::Basic => "basic",
39            ManifestProfile::PolicyEvidence => "policy-evidence",
40            ManifestProfile::Full => "full",
41        }
42    }
43}
44
45impl Default for ManifestProfile {
46    fn default() -> Self {
47        ManifestProfile::Basic
48    }
49}
50
51#[derive(Clone, Debug)]
52pub struct PolicyEvidence {
53    pub effect: String,
54    pub boundaries: Vec<String>,
55    pub reasons: Vec<String>,
56    pub previews: Vec<String>,
57    pub decision_attestation_ids: Vec<Uuid>,
58}
59
60impl PolicyEvidence {
61    pub fn is_empty(&self) -> bool {
62        self.effect.is_empty()
63            && self.boundaries.is_empty()
64            && self.reasons.is_empty()
65            && self.previews.is_empty()
66            && self.decision_attestation_ids.is_empty()
67    }
68}
69
70#[derive(Clone, Debug, Default)]
71pub struct IdentityEvidence {
72    pub run_id: Option<Uuid>,
73    pub step_id: Option<Uuid>,
74    pub subject_label: Option<String>,
75}
76
77impl IdentityEvidence {
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    pub fn with_run_id(mut self, run_id: Uuid) -> Self {
83        self.run_id = Some(run_id);
84        self
85    }
86
87    pub fn with_step_id(mut self, step_id: Uuid) -> Self {
88        self.step_id = Some(step_id);
89        self
90    }
91
92    pub fn with_subject_label(mut self, label: impl Into<String>) -> Self {
93        self.subject_label = Some(label.into());
94        self
95    }
96
97    pub fn is_empty(&self) -> bool {
98        self.run_id.is_none() && self.step_id.is_none() && self.subject_label.is_none()
99    }
100}
101
102#[derive(Clone, Debug)]
103pub struct CapabilityEvidenceEntry {
104    pub token_id: Uuid,
105    pub issued_at: DateTime<Utc>,
106    pub expires_at: DateTime<Utc>,
107    pub scope: CapabilityTokenScope,
108    pub audience: Option<Vec<String>>,
109}
110
111#[derive(Clone, Debug, Default)]
112pub struct CapabilityEvidence {
113    pub chain: Vec<CapabilityEvidenceEntry>,
114}
115
116impl CapabilityEvidence {
117    pub fn from_tokens(tokens: &[CapabilityToken]) -> Self {
118        let chain = tokens
119            .iter()
120            .map(|token| CapabilityEvidenceEntry {
121                token_id: token.claims.token_id,
122                issued_at: token.claims.issued_at,
123                expires_at: token.claims.expires_at,
124                scope: token.claims.scope.clone(),
125                audience: token.claims.audience.clone(),
126            })
127            .collect();
128        Self { chain }
129    }
130
131    pub fn is_empty(&self) -> bool {
132        self.chain.is_empty()
133    }
134}
135
136pub struct ManifestInput<'a> {
137    pub bytes: &'a [u8],
138    pub media_type: &'a str,
139    pub subject: Option<&'a str>,
140    pub attestation_ids: &'a [Uuid],
141    pub profile: ManifestProfile,
142    pub policy_evidence: Option<&'a PolicyEvidence>,
143    pub identity: Option<&'a IdentityEvidence>,
144    pub capability_evidence: Option<&'a CapabilityEvidence>,
145}
146
147/// Generates a signed C2PA-style manifest for the supplied artifact bytes.
148pub fn generate_c2pa_manifest(input: ManifestInput<'_>) -> Result<Value> {
149    let digest = digest_bytes(input.bytes);
150    let issued_at = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
151    let attestation_values: Vec<Value> = input
152        .attestation_ids
153        .iter()
154        .map(|id| Value::String(id.to_string()))
155        .collect();
156
157    let mut assertions = Map::new();
158    assertions.insert(
159        "attestation_ids".to_string(),
160        Value::Array(attestation_values),
161    );
162    assertions.insert("issued_at".to_string(), Value::String(issued_at.clone()));
163
164    if let Some(policy) = input.policy_evidence {
165        if !policy.is_empty() {
166            assertions.insert("policy".to_string(), policy_assertion(policy));
167        }
168    }
169
170    if let Some(identity) = input.identity {
171        if !identity.is_empty() {
172            assertions.insert("identity".to_string(), identity_assertion(identity));
173        }
174    }
175
176    if let Some(capabilities) = input.capability_evidence {
177        if !capabilities.is_empty() {
178            assertions.insert(
179                "capability_chain".to_string(),
180                capability_assertion(capabilities),
181            );
182        }
183    }
184
185    let manifest = json!({
186        "version": "1.0",
187        "profile": input.profile.as_str(),
188        "claim_generator": "FleetForge Trust Mesh",
189        "asset": {
190            "media_type": input.media_type,
191            "subject": input.subject.unwrap_or("fleetforge-artifact"),
192            "digest": {
193                "algorithm": "sha256",
194                "value": digest,
195            }
196        },
197        "assertions": assertions,
198    });
199
200    let signer = c2pa_signer().unwrap_or_else(|_| super::trust_signer());
201    let canonical_bytes = canonicalize(&manifest)?;
202    let signature = signer
203        .sign(&canonical_bytes)
204        .context("failed to sign C2PA manifest")?;
205
206    let envelope = C2paManifestEnvelope {
207        manifest,
208        signature,
209    };
210    Ok(serde_json::to_value(envelope)?)
211}
212
213/// Verifies the provided manifest envelope against the raw artifact bytes and signature.
214pub fn verify_c2pa_manifest(envelope: &Value, bytes: &[u8]) -> Result<VerifiedManifest> {
215    let envelope: C2paManifestEnvelope = serde_json::from_value(envelope.clone())
216        .map_err(|err| anyhow!("invalid C2PA envelope: {err}"))?;
217
218    let manifest_obj = envelope
219        .manifest
220        .as_object()
221        .ok_or_else(|| anyhow!("manifest must be a JSON object"))?;
222
223    let asset_obj = manifest_obj
224        .get("asset")
225        .and_then(Value::as_object)
226        .ok_or_else(|| anyhow!("manifest missing asset block"))?;
227
228    let digest_obj = asset_obj
229        .get("digest")
230        .and_then(Value::as_object)
231        .ok_or_else(|| anyhow!("manifest asset missing digest block"))?;
232
233    let algorithm = digest_obj
234        .get("algorithm")
235        .and_then(Value::as_str)
236        .ok_or_else(|| anyhow!("manifest digest missing algorithm"))?;
237
238    if algorithm.to_ascii_lowercase() != "sha256" {
239        return Err(anyhow!("unsupported digest algorithm '{}'", algorithm));
240    }
241
242    let expected = digest_obj
243        .get("value")
244        .and_then(Value::as_str)
245        .ok_or_else(|| anyhow!("manifest digest missing value"))?;
246
247    let actual = digest_bytes(bytes);
248
249    if expected != actual {
250        return Err(anyhow!(
251            "digest mismatch: manifest={}, computed={}",
252            expected,
253            actual
254        ));
255    }
256
257    let canonical_bytes = canonicalize(&envelope.manifest)?;
258    let signer_lookup = |kid: &str| {
259        c2pa_signer().ok().and_then(|signer| {
260            if signer.key_id() == kid {
261                Some(signer)
262            } else {
263                None
264            }
265        })
266    };
267    let verified = verify_signature_envelope(&canonical_bytes, &envelope.signature, signer_lookup)
268        .map_err(|err| anyhow!("manifest signature validation failed: {err}"))?;
269    if !verified {
270        return Err(anyhow!("manifest signature verification failed"));
271    }
272
273    let attestation_ids = envelope
274        .manifest
275        .pointer("/assertions/attestation_ids")
276        .and_then(Value::as_array)
277        .ok_or_else(|| anyhow!("manifest missing assertions.attestation_ids array"))?
278        .iter()
279        .map(|value| {
280            value
281                .as_str()
282                .ok_or_else(|| anyhow!("attestation id must be a string"))
283                .and_then(|s| {
284                    Uuid::parse_str(s).map_err(|err| anyhow!("invalid attestation id '{s}': {err}"))
285                })
286        })
287        .collect::<Result<Vec<_>>>()?;
288
289    Ok(VerifiedManifest {
290        manifest: envelope.manifest,
291        attestation_ids,
292        artifact_sha256: actual,
293    })
294}
295
296fn policy_assertion(policy: &PolicyEvidence) -> Value {
297    json!({
298        "effect": policy.effect,
299        "boundaries": policy.boundaries,
300        "reasons": policy.reasons,
301        "previews": policy.previews,
302        "decision_attestation_ids": policy
303            .decision_attestation_ids
304            .iter()
305            .map(|id| id.to_string())
306            .collect::<Vec<_>>(),
307    })
308}
309
310fn identity_assertion(identity: &IdentityEvidence) -> Value {
311    let mut map = Map::new();
312    if let Some(run) = identity.run_id {
313        map.insert("run_id".to_string(), Value::String(run.to_string()));
314    }
315    if let Some(step) = identity.step_id {
316        map.insert("step_id".to_string(), Value::String(step.to_string()));
317    }
318    if let Some(subject) = identity.subject_label.as_ref() {
319        map.insert("subject_label".to_string(), Value::String(subject.clone()));
320    }
321    Value::Object(map)
322}
323
324fn capability_assertion(capabilities: &CapabilityEvidence) -> Value {
325    let entries: Vec<Value> = capabilities
326        .chain
327        .iter()
328        .map(|entry| {
329            json!({
330                "token_id": entry.token_id,
331                "issued_at": entry.issued_at.to_rfc3339(),
332                "expires_at": entry.expires_at.to_rfc3339(),
333                "scope": entry.scope,
334                "audience": entry.audience,
335            })
336        })
337        .collect();
338    Value::Array(entries)
339}
340
341fn canonicalize(value: &Value) -> Result<Vec<u8>> {
342    fn recurse(value: &Value) -> Value {
343        match value {
344            Value::Object(map) => {
345                let mut ordered = BTreeMap::new();
346                for (key, val) in map.iter() {
347                    ordered.insert(key.clone(), recurse(val));
348                }
349                let mut object = Map::new();
350                for (key, val) in ordered {
351                    object.insert(key, val);
352                }
353                Value::Object(object)
354            }
355            Value::Array(items) => Value::Array(items.iter().map(recurse).collect()),
356            other => other.clone(),
357        }
358    }
359
360    let canonical = recurse(value);
361    serde_json::to_vec(&canonical).map_err(|err| anyhow!("failed to canonicalise manifest: {err}"))
362}