Skip to main content

c2pa_text_binding/
soft_binding.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! The `c2pa.soft-binding` assertion emitted for each algorithm in this family.
4//!
5//! This builds the normative `soft-binding-map` from the C2PA specification
6//! (`soft-binding.cddl`) as deterministic CBOR. The structure mirrors the
7//! `c2pa-rs` reference reader's `SoftBinding` type field-for-field, so the bytes
8//! produced here deserialize directly into that reader — see
9//! `tests/c2pa_roundtrip.rs`, which decodes them with the exact CBOR codec
10//! (`c2pa_cbor`) that `c2pa-rs` uses and reconstructs its `SoftBinding`.
11//!
12//! Per the CDDL, a block's `value` is algorithm-specific. The reference reader
13//! types it as a text string, so every value here is the algorithm's hex
14//! encoding (the same hex the registry descriptions record). Window scopes for
15//! the surface fingerprint are carried as textual character ranges
16//! (`region-of-interest.cddl` `Textual`); the offsets are into the algorithm's
17//! normalized character stream, which is where the fingerprint is computed.
18//!
19//! Emitting an assertion is not signing it: [`to_cbor`] returns the assertion
20//! bytes, which the caller signs with [`crate::manifest::sign_cose`]. Presence
21//! in the C2PA soft-binding algorithm list is not conformance certification.
22
23use serde::{Deserialize, Serialize};
24
25use crate::error::Error;
26use crate::minhash::MinHash;
27use crate::simhash::{Fingerprint, Hash256};
28
29/// The C2PA assertion label a soft-binding payload is stored under.
30pub const SOFT_BINDING_LABEL: &str = "c2pa.soft-binding";
31
32/// Registered algorithm identifier for `text-fingerprint.1` (list id 41).
33pub const ALG_FINGERPRINT: &str = "com.writerslogic.text-fingerprint.1";
34/// Registered algorithm identifier for `zwc-watermark.2` (list id 42).
35pub const ALG_WATERMARK: &str = "com.writerslogic.zwc-watermark.2";
36/// Registered algorithm identifier for `text-structure.1` (list id 43).
37pub const ALG_STRUCTURE: &str = "com.writerslogic.text-structure.1";
38/// Registered algorithm identifier for `text-minhash.1` (list id 44).
39pub const ALG_MINHASH: &str = "com.writerslogic.text-minhash.1";
40
41/// A `soft-binding-map`: one or more soft bindings over the asset's content.
42///
43/// Field layout and names match the C2PA CDDL and the `c2pa-rs` reader. `pad`
44/// is always emitted (an empty byte string) as the reference writer does.
45#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
46pub struct SoftBinding {
47    /// The registered soft-binding algorithm identifier.
48    pub alg: String,
49    /// One block per scoped soft-binding value.
50    pub blocks: Vec<Block>,
51    /// A human-readable description of what this binding covers.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub name: Option<String>,
54    /// Zero-filled padding; kept empty. Present to match the reference writer.
55    #[serde(with = "serde_bytes")]
56    pub pad: Vec<u8>,
57}
58
59/// A single `soft-binding-block-map`: a scope plus the value over that scope.
60#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
61pub struct Block {
62    /// Where in the content this value applies.
63    pub scope: Scope,
64    /// The algorithm-specific value (hex) over this block of content.
65    pub value: String,
66}
67
68/// A `soft-binding-scope-map`. Only the textual `region` is used by this family;
69/// an empty scope means the whole asset.
70#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
71pub struct Scope {
72    /// A region of interest bounding this block (textual character range).
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub region: Option<RegionOfInterest>,
75}
76
77/// A minimal region of interest carrying one or more `Range`s.
78#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
79pub struct RegionOfInterest {
80    /// The ranges making up this region.
81    pub region: Vec<Range>,
82}
83
84/// A single range. Only the textual variant is produced here.
85#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
86pub struct Range {
87    /// Range discriminator; always [`RangeType::Textual`] here.
88    #[serde(rename = "type")]
89    pub range_type: RangeType,
90    /// The textual selection for a `Textual` range.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub text: Option<Text>,
93}
94
95/// The C2PA `RangeType` discriminator (camelCase on the wire).
96#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub enum RangeType {
99    /// A textual character-offset range.
100    Textual,
101}
102
103/// A textual range: one or more character-offset selectors.
104#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
105pub struct Text {
106    /// The selected sub-ranges.
107    pub selectors: Vec<TextSelectorRange>,
108}
109
110/// One `[start, end)` character range over the normalized stream.
111#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
112pub struct TextSelectorRange {
113    /// The selector giving the start and end character offsets.
114    pub selector: TextSelector,
115}
116
117/// A character-offset selector. `fragment` is required by the C2PA type; for
118/// plain text there is no sub-resource, so it is empty.
119#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
120pub struct TextSelector {
121    /// Sub-resource fragment identifier; empty for plain text.
122    pub fragment: String,
123    /// Start character offset into the normalized stream.
124    pub start: Option<i32>,
125    /// End character offset (exclusive) into the normalized stream.
126    pub end: Option<i32>,
127}
128
129impl SoftBinding {
130    /// A whole-asset soft binding: one block with an empty scope.
131    pub fn whole(alg: &str, value_hex: String) -> Self {
132        SoftBinding {
133            alg: alg.to_string(),
134            blocks: vec![Block {
135                scope: Scope::default(),
136                value: value_hex,
137            }],
138            name: None,
139            pad: Vec::new(),
140        }
141    }
142
143    /// Push a block scoped to the character range `[start, start+len)` of the
144    /// normalized stream. Used to record the surface fingerprint's windows.
145    pub fn push_window(&mut self, start: usize, len: usize, value_hex: String) {
146        let s = i32::try_from(start).unwrap_or(i32::MAX);
147        let e = i32::try_from(start.saturating_add(len)).unwrap_or(i32::MAX);
148        self.blocks.push(Block {
149            scope: Scope {
150                region: Some(RegionOfInterest {
151                    region: vec![Range {
152                        range_type: RangeType::Textual,
153                        text: Some(Text {
154                            selectors: vec![TextSelectorRange {
155                                selector: TextSelector {
156                                    fragment: String::new(),
157                                    start: Some(s),
158                                    end: Some(e),
159                                },
160                            }],
161                        }),
162                    }],
163                }),
164            },
165            value: value_hex,
166        });
167    }
168
169    /// Serialize to deterministic CBOR (the assertion payload to be signed).
170    pub fn to_cbor(&self) -> Result<Vec<u8>, Error> {
171        let mut out = Vec::new();
172        ciborium::into_writer(self, &mut out)
173            .map_err(|e| Error::GenerationFailed(format!("soft-binding CBOR encode: {e}")))?;
174        Ok(out)
175    }
176
177    /// Parse a soft-binding assertion back from CBOR (for verifiers and tests).
178    pub fn from_cbor(bytes: &[u8]) -> Result<Self, Error> {
179        ciborium::from_reader(bytes)
180            .map_err(|e| Error::InvalidInput(format!("soft-binding CBOR decode: {e}")))
181    }
182}
183
184/// Build the `text-fingerprint.1` (list id 41) soft binding: the whole-document
185/// SimHash plus one scoped block per overlapping window.
186pub fn from_fingerprint(fp: &Fingerprint) -> SoftBinding {
187    let mut sb = SoftBinding::whole(ALG_FINGERPRINT, fp.whole.to_hex());
188    for w in &fp.windows {
189        sb.push_window(w.start, w.len, w.hash.to_hex());
190    }
191    sb
192}
193
194/// Build the `text-structure.1` (list id 43) soft binding: the whole-document
195/// structural SimHash.
196pub fn from_structure(hash: &Hash256) -> SoftBinding {
197    SoftBinding::whole(ALG_STRUCTURE, hash.to_hex())
198}
199
200/// Build the `text-minhash.1` (list id 44) soft binding. The value is the 128
201/// signature values as big-endian `u64` bytes, hex-encoded — a deterministic,
202/// verifier-reproducible serialization of the recorded signature.
203pub fn from_minhash(mh: &MinHash) -> SoftBinding {
204    let mut bytes = Vec::with_capacity(mh.sig.len() * 8);
205    for v in &mh.sig {
206        bytes.extend_from_slice(&v.to_be_bytes());
207    }
208    SoftBinding::whole(ALG_MINHASH, hex::encode(bytes))
209}
210
211/// Build the `zwc-watermark.2` (list id 42) soft binding. The value is the
212/// routing pointer embedded in the carrier, hex-encoded — the identifier a
213/// manifest repository is keyed by.
214pub fn from_watermark_pointer(pointer: &[u8]) -> SoftBinding {
215    SoftBinding::whole(ALG_WATERMARK, hex::encode(pointer))
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn whole_binding_roundtrips_through_cbor() {
224        let sb = SoftBinding::whole(ALG_FINGERPRINT, "deadbeef".into());
225        let bytes = sb.to_cbor().unwrap();
226        assert_eq!(SoftBinding::from_cbor(&bytes).unwrap(), sb);
227    }
228
229    #[test]
230    fn windows_carry_textual_char_ranges() {
231        let mut sb = SoftBinding::whole(ALG_FINGERPRINT, "00".into());
232        sb.push_window(0, 512, "11".into());
233        sb.push_window(256, 512, "22".into());
234        assert_eq!(sb.blocks.len(), 3);
235        let win = &sb.blocks[1];
236        let sel = &win.scope.region.as_ref().unwrap().region[0]
237            .text
238            .as_ref()
239            .unwrap()
240            .selectors[0]
241            .selector;
242        assert_eq!(sel.start, Some(0));
243        assert_eq!(sel.end, Some(512));
244        // Whole-asset block one has no region.
245        assert!(sb.blocks[0].scope.region.is_none());
246        let bytes = sb.to_cbor().unwrap();
247        assert_eq!(SoftBinding::from_cbor(&bytes).unwrap(), sb);
248    }
249
250    #[test]
251    fn alg_is_required_and_present() {
252        let sb = SoftBinding::whole(ALG_WATERMARK, "abcd".into());
253        let bytes = sb.to_cbor().unwrap();
254        // The decoded map must carry a non-empty alg (CDDL: no default).
255        let back = SoftBinding::from_cbor(&bytes).unwrap();
256        assert_eq!(back.alg, ALG_WATERMARK);
257        assert!(!back.blocks.is_empty());
258    }
259}