Skip to main content

dpp_domain/domain/
identity.rs

1//! Identity and access types: `Audience`, `Disclosure`, `SignedCredential`, and `PassportCredential`.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use serde_json::{Value, json};
6
7/// Who is asking for passport data.
8///
9/// Regulation (EU) 2023/1542 Art. 77(2) names three audiences and assigns each
10/// a set of Annex XIII data points:
11///
12/// | Audience | Annex XIII |
13/// |---|---|
14/// | (a) general public | 1 |
15/// | (b) notified bodies, market surveillance authorities, the Commission | 2 and 3 |
16/// | (c) persons with a legitimate interest | 2 and 4 |
17///
18/// **This is a lattice, not a ranking.** Point 3 (conformity test reports) is
19/// authority-only; point 4 (individual-item use history) is
20/// legitimate-interest-only. Neither audience contains the other, so no integer
21/// ordering can express the assignment: any `>=` comparison necessarily either
22/// hands authorities the individual-item data Art. 77(2)(b) withholds, or hides
23/// point-2 data from someone entitled to it.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26#[non_exhaustive]
27pub enum Audience {
28    /// Anyone, with no credential. Art. 77(2)(a).
29    Public,
30    /// A repairer, remanufacturer, second-life operator or recycler holding a
31    /// credential that proves the interest. Art. 77(2)(c).
32    LegitimateInterest,
33    /// Notified body, market surveillance authority, customs, or the
34    /// Commission. Art. 77(2)(b).
35    Authority,
36}
37
38/// How restricted a field is — the counterpart to [`Audience`].
39///
40/// Named for the Annex XIII point each class corresponds to, and kept
41/// sector-agnostic so non-battery sectors reuse the same vocabulary.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44#[non_exhaustive]
45pub enum Disclosure {
46    /// Publicly accessible. Annex XIII point 1.
47    Public,
48    /// Detailed composition, dismantling information, safety measures.
49    /// Annex XIII point 2 — visible to **both** non-public audiences.
50    Restricted,
51    /// Conformity evidence: results of test reports. Annex XIII point 3 —
52    /// authorities only.
53    Conformity,
54    /// Information and data relating to an **individual** item: use history,
55    /// cycle counts, negative events, state of health, status. Annex XIII
56    /// point 4 — legitimate interest only, and explicitly **not** authorities.
57    Individual,
58}
59
60/// Disclosure class of every top-level passport field that is not public.
61///
62/// **The single source for this fact.** `Passport::redact` and the crypto
63/// layer's `SectorAccessPolicy::passport_default()` both read it, because they
64/// previously each carried their own copy and drifted: the policy classified
65/// `lintResult` as restricted while `redact` never removed it, so a public view
66/// built through the domain path disclosed it.
67///
68/// Fields absent from this list are [`Disclosure::Public`].
69pub const PASSPORT_FIELD_DISCLOSURE: &[(&str, Disclosure)] = &[
70    ("batchId", Disclosure::Restricted),
71    // Advisory plausibility output, re-computable after publish and carrying
72    // free-text findings about our own data quality — operator- and
73    // auditor-facing, not consumer-facing.
74    ("lintResult", Disclosure::Restricted),
75    ("jwsSignature", Disclosure::Conformity),
76    ("retentionLocked", Disclosure::Conformity),
77];
78
79impl Disclosure {
80    /// The stable wire token for this class, used to build a disclosure-set key.
81    ///
82    /// Deliberately not `Serialize`-derived: this string is baked into stored
83    /// artefact keys, so it must be stable independently of any future serde
84    /// attribute change on the enum.
85    #[must_use]
86    pub const fn token(self) -> &'static str {
87        match self {
88            Self::Public => "public",
89            Self::Restricted => "restricted",
90            Self::Conformity => "conformity",
91            Self::Individual => "individual",
92        }
93    }
94}
95
96/// Every disclosure class, in the fixed order a [`disclosure_key`] uses.
97///
98/// Ordering is by Annex XIII point number, and it is part of the key format:
99/// two nodes must produce byte-identical keys for the same set.
100const DISCLOSURE_ORDER: &[Disclosure] = &[
101    Disclosure::Public,
102    Disclosure::Restricted,
103    Disclosure::Conformity,
104    Disclosure::Individual,
105];
106
107/// Name a set of disclosure classes: the classes' tokens in Annex XIII order,
108/// joined with `+` — e.g. `public+restricted+individual`.
109///
110/// **This is how durable artefacts are keyed, and it must never be an audience
111/// name.** ESPR uses a ~14-class actor vocabulary that is not battery's
112/// three-audience lattice, and the delegated act mapping actors to data does not
113/// exist yet. A signature or audit row keyed `"legitimateInterest"` would have
114/// to be migrated the day that mapping lands; one keyed by the disclosure set it
115/// actually covers keeps meaning exactly what it always meant, and a new actor
116/// taxonomy becomes a new mapping onto the same keys.
117#[must_use]
118pub fn disclosure_key(classes: &[Disclosure]) -> String {
119    DISCLOSURE_ORDER
120        .iter()
121        .filter(|c| classes.contains(c))
122        .map(|c| c.token())
123        .collect::<Vec<_>>()
124        .join("+")
125}
126
127impl Audience {
128    /// The disclosure classes this audience may see, in Annex XIII order.
129    #[must_use]
130    pub fn disclosure_set(self) -> Vec<Disclosure> {
131        DISCLOSURE_ORDER
132            .iter()
133            .copied()
134            .filter(|d| self.may_see(*d))
135            .collect()
136    }
137
138    /// The [`disclosure_key`] for this audience's classes — the name under which
139    /// a view served to it is signed and audited.
140    ///
141    /// Two audiences with the same class set would share a key, and that is
142    /// correct: the artefact describes the data it covers, not who asked.
143    #[must_use]
144    pub fn disclosure_key(self) -> String {
145        disclosure_key(&self.disclosure_set())
146    }
147
148    /// Whether this audience may see a field of class `disclosure`.
149    ///
150    /// The whole Art. 77(2) assignment, in one table.
151    #[must_use]
152    pub const fn may_see(self, disclosure: Disclosure) -> bool {
153        matches!(
154            (self, disclosure),
155            (Self::Public, Disclosure::Public)
156                | (
157                    Self::LegitimateInterest,
158                    Disclosure::Public | Disclosure::Restricted | Disclosure::Individual
159                )
160                | (
161                    Self::Authority,
162                    Disclosure::Public | Disclosure::Restricted | Disclosure::Conformity
163                )
164        )
165    }
166}
167
168/// A W3C Verifiable Credential 2.0 envelope binding a DPP passport to its signed payload.
169///
170/// The cryptographic proof is in [`SignedCredential::jws`]; this struct provides
171/// the structured VC context required for EUDI/EBSI interoperability.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173#[serde(rename_all = "camelCase")]
174pub struct PassportCredential {
175    #[serde(rename = "@context")]
176    pub context: Vec<Value>,
177    #[serde(rename = "type")]
178    pub credential_type: Vec<String>,
179    /// Unique credential ID (`urn:uuid:…`) — generated fresh per signing call.
180    pub id: String,
181    /// DID of the signing issuer (`did:web:…`).
182    pub issuer: String,
183    /// Credential issuance timestamp (W3C VC 2.0 `validFrom`).
184    pub valid_from: DateTime<Utc>,
185    pub credential_subject: PassportCredentialSubject,
186}
187
188impl PassportCredential {
189    /// W3C VCDM v2 base context — MUST be the first `@context` entry.
190    pub const VC_BASE_CONTEXT: &'static str = "https://www.w3.org/ns/credentials/v2";
191
192    /// Inline JSON-LD term map for the DPP-specific terms this credential adds
193    /// on top of the VCDM v2 base context: the credential type value and the
194    /// one custom subject property (`payloadHash`).
195    ///
196    /// Inlined rather than hosted at a URL — a string entry in `@context` is
197    /// fetched by the consumer at expansion time, and this crate does not host
198    /// a context document. Same reasoning and `dpp:` prefix as
199    /// `dpp_vc::jsonld::context::passport_context`; a prefix IRI names a
200    /// vocabulary and is never dereferenced during expansion, so it carries no
201    /// such obligation.
202    fn dpp_terms() -> Value {
203        json!({
204            "dpp": "https://schema.odal-node.io/dpp#",
205            "DppPassportCredential": "dpp:DppPassportCredential",
206            "payloadHash": "dpp:payloadHash",
207        })
208    }
209
210    /// Construct a passport credential with the VCDM v2 base context and the
211    /// `VerifiableCredential` base type guaranteed present, so a caller cannot
212    /// emit a VC missing `https://www.w3.org/ns/credentials/v2`. `id`
213    /// (`urn:uuid:` v7) and `valid_from` are generated fresh.
214    #[must_use]
215    pub fn new(issuer: String, credential_subject: PassportCredentialSubject) -> Self {
216        Self {
217            context: vec![json!(Self::VC_BASE_CONTEXT), Self::dpp_terms()],
218            credential_type: vec![
219                "VerifiableCredential".into(),
220                "DppPassportCredential".into(),
221            ],
222            id: format!("urn:uuid:{}", uuid::Uuid::now_v7()),
223            issuer,
224            valid_from: Utc::now(),
225            credential_subject,
226        }
227    }
228}
229
230/// Claims about the DPP passport being attested.
231#[derive(Debug, Clone, Serialize, Deserialize)]
232#[serde(rename_all = "camelCase")]
233pub struct PassportCredentialSubject {
234    /// `urn:uuid:{passport_id}` — the DPP passport being attested.
235    pub id: String,
236    /// SHA-256 hex digest of the RFC 8785 canonical payload bytes.
237    pub payload_hash: String,
238}
239
240/// A DPP Verifiable Credential with its JWS proof signature.
241#[derive(Debug, Clone, Serialize, Deserialize)]
242#[serde(rename_all = "camelCase")]
243pub struct SignedCredential {
244    /// Structured W3C VC 2.0 passport credential.
245    pub credential: PassportCredential,
246    /// Compact JWS signature string (header.payload.signature).
247    pub jws: String,
248    /// The DID of the issuer (manufacturer or Odal on their behalf).
249    pub issuer_did: String,
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn authorities_do_not_see_individual_item_data() {
258        // Art. 77(2)(b) assigns notified bodies, market surveillance and the
259        // Commission Annex XIII points 2 and 3 — not point 4. This is the case
260        // an ordinal tier cannot express, and the reason the model changed.
261        assert!(!Audience::Authority.may_see(Disclosure::Individual));
262        assert!(Audience::LegitimateInterest.may_see(Disclosure::Individual));
263    }
264
265    #[test]
266    fn legitimate_interest_does_not_see_conformity_evidence() {
267        // Point 3 (test reports) is authority-only under Art. 77(2)(b).
268        assert!(!Audience::LegitimateInterest.may_see(Disclosure::Conformity));
269        assert!(Audience::Authority.may_see(Disclosure::Conformity));
270    }
271
272    /// The key names the *data*, not the asker. This is the property that lets a
273    /// stored signature survive ESPR naming a different actor taxonomy, so it is
274    /// asserted literally rather than round-tripped.
275    #[test]
276    fn a_disclosure_key_names_classes_never_an_audience() {
277        assert_eq!(Audience::Public.disclosure_key(), "public");
278        assert_eq!(
279            Audience::LegitimateInterest.disclosure_key(),
280            "public+restricted+individual"
281        );
282        assert_eq!(
283            Audience::Authority.disclosure_key(),
284            "public+restricted+conformity"
285        );
286
287        for audience in [
288            Audience::Public,
289            Audience::LegitimateInterest,
290            Audience::Authority,
291        ] {
292            let key = audience.disclosure_key();
293            for name in ["legitimate", "authority", "public_", "audience"] {
294                assert!(
295                    !key.contains(name) || key == "public",
296                    "{key} leaks an audience name into a durable artefact key"
297                );
298            }
299        }
300    }
301
302    /// Key construction is order-independent in its input and fixed in its
303    /// output: two nodes handed the same set in different orders must produce
304    /// byte-identical keys, or the same view signs under two names.
305    #[test]
306    fn a_disclosure_key_is_canonical_regardless_of_input_order() {
307        let forward = disclosure_key(&[
308            Disclosure::Public,
309            Disclosure::Restricted,
310            Disclosure::Individual,
311        ]);
312        let reversed = disclosure_key(&[
313            Disclosure::Individual,
314            Disclosure::Restricted,
315            Disclosure::Public,
316        ]);
317        assert_eq!(forward, reversed);
318        assert_eq!(forward, "public+restricted+individual");
319        assert_eq!(disclosure_key(&[]), "");
320    }
321
322    /// The set an audience is keyed by must be exactly what `may_see` grants —
323    /// if these drift, a view is signed under a key that overstates or
324    /// understates what it contains.
325    #[test]
326    fn the_disclosure_set_agrees_with_may_see() {
327        for audience in [
328            Audience::Public,
329            Audience::LegitimateInterest,
330            Audience::Authority,
331        ] {
332            let set = audience.disclosure_set();
333            for class in [
334                Disclosure::Public,
335                Disclosure::Restricted,
336                Disclosure::Conformity,
337                Disclosure::Individual,
338            ] {
339                assert_eq!(
340                    set.contains(&class),
341                    audience.may_see(class),
342                    "{audience:?} / {class:?}: disclosure_set disagrees with may_see"
343                );
344            }
345        }
346    }
347
348    #[test]
349    fn neither_non_public_audience_contains_the_other() {
350        // The defining property of a lattice: if either audience were a superset
351        // of the other, an ordinal ranking would suffice and this type would be
352        // unnecessary. Each sees something the other does not.
353        let all = [
354            Disclosure::Public,
355            Disclosure::Restricted,
356            Disclosure::Conformity,
357            Disclosure::Individual,
358        ];
359        let authority_only = all
360            .iter()
361            .any(|d| Audience::Authority.may_see(*d) && !Audience::LegitimateInterest.may_see(*d));
362        let interest_only = all
363            .iter()
364            .any(|d| Audience::LegitimateInterest.may_see(*d) && !Audience::Authority.may_see(*d));
365        assert!(authority_only && interest_only);
366    }
367
368    #[test]
369    fn point_two_is_shared_and_public_is_universal() {
370        for audience in [
371            Audience::Public,
372            Audience::LegitimateInterest,
373            Audience::Authority,
374        ] {
375            assert!(audience.may_see(Disclosure::Public));
376        }
377        // Annex XIII point 2 goes to both non-public audiences.
378        assert!(Audience::LegitimateInterest.may_see(Disclosure::Restricted));
379        assert!(Audience::Authority.may_see(Disclosure::Restricted));
380        assert!(!Audience::Public.may_see(Disclosure::Restricted));
381    }
382
383    #[test]
384    fn public_sees_nothing_restricted() {
385        for class in [
386            Disclosure::Restricted,
387            Disclosure::Conformity,
388            Disclosure::Individual,
389        ] {
390            assert!(!Audience::Public.may_see(class));
391        }
392    }
393
394    #[test]
395    fn new_passport_credential_guarantees_vc_base_context_and_type() {
396        let vc = PassportCredential::new(
397            "did:web:issuer.example.com".into(),
398            PassportCredentialSubject {
399                id: "urn:uuid:00000000-0000-0000-0000-000000000000".into(),
400                payload_hash: "deadbeef".into(),
401            },
402        );
403        // VCDM v2 requires the base context to be the first @context entry.
404        assert_eq!(
405            vc.context.first().and_then(Value::as_str),
406            Some(PassportCredential::VC_BASE_CONTEXT)
407        );
408        assert!(
409            vc.credential_type
410                .contains(&"VerifiableCredential".to_string())
411        );
412        assert!(vc.id.starts_with("urn:uuid:"));
413    }
414}