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}