Skip to main content

auths_keri/
acdc.rs

1//! ACDC (Authentic Chained Data Container) credential type for Auths.
2//!
3//! An ACDC is a SAID'd JSON credential anchored to a KEL — the same SAID-ification
4//! machinery as KEL events, under the `ACDC10JSON` protocol tag instead of
5//! `KERI10JSON`. The v1 shape is `{v, d, i, ri, s, a}`:
6//!
7//! - `v` — version string `ACDC10JSON{size:06x}_`.
8//! - `d` — credential SAID (Blake3-256, CESR `E…`).
9//! - `i` — issuer AID (a KERI `did:keri:` prefix; curve-tagged via its inception keys).
10//! - `ri` — registry (status) SAID, anchoring revocation state.
11//! - `s` — schema SAID (the immutable [`CAPABILITY_SCHEMA`] SAID).
12//! - `a` — attributes block with its own nested SAID `a.d` and a holder-bindable
13//!   subject `a.i` that is a KERI AID (F.8 enforces holder control).
14//!
15//! ## Forward-compatibility (honest)
16//!
17//! The SAID is computed with keripy 1.3.4's ACDC algorithm. A future **top-level
18//! `e` (edges)** block re-runs the same algorithm over the larger body: a v1
19//! credential that has no `e` keeps its SAID, and an edged credential's `a.d` is
20//! unchanged because `a` is untouched. Adding `e` does change the *top-level* `d`
21//! (the digest covers the whole body) — so edges are an additive *layout*, not a
22//! SAID-preserving mutation.
23//!
24//! **Selective disclosure (`u`/`A`) is NOT additive.** The blinding nonce `u` lives
25//! *inside* the attributes block, changing `a.d` (hence the top-level `d`). SD is
26//! therefore a SAID-breaking **v2** (new schema SAID / version), not a drop-in. This
27//! module makes no SD forward-compat claim.
28
29use serde::{Deserialize, Serialize};
30
31use crate::said::{Protocol, compute_said_with_protocol, compute_section_said};
32use crate::types::{Prefix, Said};
33
34/// Pinned keripy revision whose ACDC SAID algorithm these types reproduce byte-for-byte.
35pub const ACDC_KERIPY_REVISION: &str = "keripy 1.3.4";
36
37/// The 17-char ACDC version-string prefix family (`ACDC10JSON…`).
38pub const ACDC_VERSION_PREFIX: &str = "ACDC10JSON";
39
40/// The pinned v1 capability schema document (JSON-Schema-2020-12), with its
41/// immutable schema SAID already substituted into `$id`.
42///
43/// This is the document `s` pins and that F.5 embeds for offline/WASM validation.
44/// Its SAID is computed by SAID-ifying the *schema document* under the `$id` label
45/// (distinct from credential SAID-ification under `d`) — see
46/// [`compute_capability_schema_said`].
47pub const CAPABILITY_SCHEMA: &str = include_str!("acdc_capability_schema.json");
48
49/// Errors raised while constructing, SAID-ifying, or verifying an [`Acdc`].
50#[derive(Debug, thiserror::Error)]
51pub enum AcdcError {
52    /// The credential body could not be serialized to JSON.
53    #[error("ACDC serialization failed: {0}")]
54    Serialization(#[from] serde_json::Error),
55
56    /// SAID computation failed at the credential or attributes layer.
57    #[error("ACDC SAID computation failed: {0}")]
58    Said(#[from] crate::error::KeriTranslationError),
59
60    /// A computed SAID did not match the one carried in the credential.
61    #[error("ACDC {layer} SAID mismatch: computed {computed}, found {found}")]
62    SaidMismatch {
63        /// Which layer mismatched (`credential` or `attributes`).
64        layer: &'static str,
65        /// The SAID recomputed from the body.
66        computed: String,
67        /// The SAID carried in the credential.
68        found: String,
69    },
70}
71
72/// The attributes (`a`) block of an ACDC — the holder-bound subject claims.
73///
74/// Serializes in strict insertion order `{d, i, dt, <data…>}` to match keripy.
75/// `d` is the nested section SAID; `i` is the subject (holder) AID; `dt` is the
76/// issuance datetime; any further claim fields ride in `data` (insertion-ordered).
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct Attributes {
79    /// Nested attributes SAID (Blake3-256 over this block with `d` placeholder-filled).
80    pub d: Said,
81    /// Subject (holder) AID — a curve-tagged KERI prefix; holder-bindable for F.8.
82    pub i: Prefix,
83    /// ISO-8601 issuance datetime.
84    pub dt: String,
85    /// Remaining subject claim fields, serialized in insertion order after `dt`.
86    #[serde(flatten)]
87    pub data: serde_json::Map<String, serde_json::Value>,
88}
89
90/// An Authentic Chained Data Container credential (`{v, d, i, ri, s, a}`).
91///
92/// Construct unsaided fields via [`Acdc::new`], then [`Acdc::saidify`] to compute
93/// `a.d` and `d`. Strict field order `v, d, i, ri, s, a` is preserved on the wire.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct Acdc {
96    /// Version string `ACDC10JSON{size:06x}_`.
97    pub v: String,
98    /// Credential SAID (Blake3-256, CESR `E…`).
99    pub d: Said,
100    /// Issuer AID (a KERI `did:keri:` prefix).
101    pub i: Prefix,
102    /// Registry (status) SAID.
103    pub ri: Said,
104    /// Schema SAID.
105    pub s: Said,
106    /// Attributes block (subject claims) with its own nested SAID `a.d`.
107    pub a: Attributes,
108}
109
110/// The placeholder version string used before the two-pass size computation.
111const ACDC_VERSION_PLACEHOLDER: &str = "ACDC10JSON000000_";
112
113impl Acdc {
114    /// Builds an un-SAID'd ACDC; call [`Acdc::saidify`] to fill `a.d` then `d`.
115    ///
116    /// Args:
117    /// * `issuer`: Issuer AID (`i`).
118    /// * `registry`: Registry/status SAID (`ri`).
119    /// * `schema`: Schema SAID (`s`).
120    /// * `subject`: Subject (holder) AID (`a.i`), a curve-tagged KERI prefix.
121    /// * `dt`: ISO-8601 issuance datetime (`a.dt`).
122    /// * `data`: Additional subject claim fields, appended after `dt` in order.
123    ///
124    /// Usage:
125    /// ```ignore
126    /// let acdc = Acdc::new(issuer, registry, schema, subject, dt, data).saidify()?;
127    /// ```
128    pub fn new(
129        issuer: Prefix,
130        registry: Said,
131        schema: Said,
132        subject: Prefix,
133        dt: String,
134        data: serde_json::Map<String, serde_json::Value>,
135    ) -> Self {
136        Self {
137            v: ACDC_VERSION_PLACEHOLDER.to_string(),
138            d: Said::default(),
139            i: issuer,
140            ri: registry,
141            s: schema,
142            a: Attributes {
143                d: Said::default(),
144                i: subject,
145                dt,
146                data,
147            },
148        }
149    }
150
151    /// Computes the nested `a.d` and top-level `d` SAIDs and the `v` size, in place.
152    ///
153    /// Two-stage, matching keripy: SAID-ify the attributes section first (no version
154    /// string), substitute `a.d`, then SAID-ify the whole credential under
155    /// [`Protocol::Acdc`] (`ACDC10JSON…`) and substitute `d` and the sized `v`.
156    ///
157    /// Usage:
158    /// ```ignore
159    /// let acdc = Acdc::new(/* … */).saidify()?;
160    /// assert!(acdc.verify_said().is_ok());
161    /// ```
162    pub fn saidify(mut self) -> Result<Self, AcdcError> {
163        let attr_value = serde_json::to_value(&self.a)?;
164        self.a.d = compute_section_said(&attr_value)?;
165
166        let body = serde_json::to_value(&self)?;
167        self.d = compute_said_with_protocol(&body, Protocol::Acdc)?;
168        self.v = self.recompute_version_string()?;
169        Ok(self)
170    }
171
172    /// Re-derives the `ACDC10JSON{size}_` version string for the current body.
173    fn recompute_version_string(&self) -> Result<String, AcdcError> {
174        let mut probe = self.clone();
175        probe.v = ACDC_VERSION_PLACEHOLDER.to_string();
176        let bytes = serde_json::to_vec(&probe)?;
177        Ok(format!("{ACDC_VERSION_PREFIX}{:06x}_", bytes.len()))
178    }
179
180    /// Verifies the carried `a.d` and `d` SAIDs against a fresh recomputation.
181    ///
182    /// Usage:
183    /// ```ignore
184    /// acdc.verify_said()?; // Err(AcdcError::SaidMismatch) if tampered.
185    /// ```
186    pub fn verify_said(&self) -> Result<(), AcdcError> {
187        let attr_value = serde_json::to_value(&self.a)?;
188        let attr_computed = compute_section_said(&attr_value)?;
189        if attr_computed != self.a.d {
190            return Err(AcdcError::SaidMismatch {
191                layer: "attributes",
192                computed: attr_computed.into_inner(),
193                found: self.a.d.as_str().to_string(),
194            });
195        }
196
197        let body = serde_json::to_value(self)?;
198        let computed = compute_said_with_protocol(&body, Protocol::Acdc)?;
199        if computed != self.d {
200            return Err(AcdcError::SaidMismatch {
201                layer: "credential",
202                computed: computed.into_inner(),
203                found: self.d.as_str().to_string(),
204            });
205        }
206        Ok(())
207    }
208
209    /// Serializes the credential to its canonical insertion-order JSON bytes.
210    ///
211    /// Usage:
212    /// ```ignore
213    /// let wire = acdc.to_wire_bytes()?;
214    /// ```
215    pub fn to_wire_bytes(&self) -> Result<Vec<u8>, AcdcError> {
216        Ok(serde_json::to_vec(self)?)
217    }
218}
219
220/// Computes the immutable SAID of the pinned capability schema document.
221///
222/// Schema SAID-ification SAID-ifies the *schema document* under the `$id` label
223/// (not the `d` label used for credentials/events): blank `$id` with the 44-char
224/// placeholder, serialize the document in insertion order, Blake3-256, CESR `E…`.
225/// keripy's `coring.Saider(sad=schema, label="$id")` is the oracle.
226///
227/// Usage:
228/// ```ignore
229/// let said = compute_capability_schema_said()?;
230/// ```
231pub fn compute_capability_schema_said() -> Result<Said, AcdcError> {
232    let doc: serde_json::Value = serde_json::from_str(CAPABILITY_SCHEMA)?;
233    compute_schema_said(&doc)
234}
235
236/// Computes a schema SAID (SAID-ification under the `$id` label).
237///
238/// Args:
239/// * `schema`: The schema document as JSON; its `$id` is placeholder-filled before hashing.
240///
241/// Usage:
242/// ```ignore
243/// let said = compute_schema_said(&schema_json)?;
244/// ```
245pub fn compute_schema_said(schema: &serde_json::Value) -> Result<Said, AcdcError> {
246    let obj = schema
247        .as_object()
248        .ok_or(crate::error::KeriTranslationError::MissingField { field: "schema" })?;
249
250    let placeholder = serde_json::Value::String(crate::said::SAID_PLACEHOLDER.to_string());
251    let mut probe = serde_json::Map::new();
252    for (k, v) in obj {
253        if k == "$id" {
254            probe.insert("$id".to_string(), placeholder.clone());
255        } else {
256            probe.insert(k.clone(), v.clone());
257        }
258    }
259    if !probe.contains_key("$id") {
260        probe.insert("$id".to_string(), placeholder.clone());
261    }
262
263    let serialized = serde_json::to_vec(&serde_json::Value::Object(probe))
264        .map_err(crate::error::KeriTranslationError::SerializationFailed)?;
265    let hash = blake3::hash(&serialized);
266    #[allow(clippy::expect_used)] // INVARIANT: a 32-byte Blake3 digest always CESR-encodes
267    let said = crate::cesr_encode::encode_blake3_digest(hash.as_bytes())
268        .expect("32-byte Blake3 digest always encodes as a CESR Blake3_256 SAID");
269    Ok(Said::new_unchecked(said))
270}