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    /// How many of the three audiences may see this class.
81    ///
82    /// Not an ordering of the lattice — there isn't one. It is the only totally
83    /// ordered thing the lattice offers, which is what a deterministic tie-break
84    /// needs.
85    const fn audience_count(self) -> u8 {
86        match self {
87            // Everyone.
88            Self::Public => 3,
89            // Legitimate interest and authorities, not the public.
90            Self::Restricted => 2,
91            // Exactly one audience each, and not the same one.
92            Self::Conformity | Self::Individual => 1,
93        }
94    }
95
96    /// The more restrictive of two classes — the one fewer audiences may see.
97    ///
98    /// Exists so that an ambiguous lookup resolves the safe way and resolves it
99    /// **identically every time**. Used by
100    /// [`SectorAccessPolicy::disclosure_for_field`](crate::access::SectorAccessPolicy::disclosure_for_field)
101    /// when two normalized-equal keys both match.
102    ///
103    /// `Conformity` and `Individual` are genuinely incomparable — Art. 77(2)
104    /// gives each to one audience, and neither audience contains the other, so
105    /// no class means "withheld from both". The tie-break returns `Individual`,
106    /// which is a choice rather than a derivation, and it is only ever reached
107    /// by a policy that declares one field name in both classes. That is an
108    /// authoring error a schema cannot commit: `access::tests` rejects it at
109    /// build time.
110    #[must_use]
111    pub const fn most_restrictive(self, other: Self) -> Self {
112        if other.audience_count() <= self.audience_count() {
113            other
114        } else {
115            self
116        }
117    }
118
119    /// The stable wire token for this class, used to build a disclosure-set key.
120    ///
121    /// Deliberately not `Serialize`-derived: this string is baked into stored
122    /// artefact keys, so it must be stable independently of any future serde
123    /// attribute change on the enum.
124    #[must_use]
125    pub const fn token(self) -> &'static str {
126        match self {
127            Self::Public => "public",
128            Self::Restricted => "restricted",
129            Self::Conformity => "conformity",
130            Self::Individual => "individual",
131        }
132    }
133}
134
135/// Every disclosure class, in the fixed order a [`disclosure_key`] uses.
136///
137/// Ordering is by Annex XIII point number, and it is part of the key format:
138/// two nodes must produce byte-identical keys for the same set.
139const DISCLOSURE_ORDER: &[Disclosure] = &[
140    Disclosure::Public,
141    Disclosure::Restricted,
142    Disclosure::Conformity,
143    Disclosure::Individual,
144];
145
146/// Name a set of disclosure classes: the classes' tokens in Annex XIII order,
147/// joined with `+` — e.g. `public+restricted+individual`.
148///
149/// **This is how durable artefacts are keyed, and it must never be an audience
150/// name.** ESPR uses a ~14-class actor vocabulary that is not battery's
151/// three-audience lattice, and the delegated act mapping actors to data does not
152/// exist yet. A signature or audit row keyed `"legitimateInterest"` would have
153/// to be migrated the day that mapping lands; one keyed by the disclosure set it
154/// actually covers keeps meaning exactly what it always meant, and a new actor
155/// taxonomy becomes a new mapping onto the same keys.
156#[must_use]
157pub fn disclosure_key(classes: &[Disclosure]) -> String {
158    DISCLOSURE_ORDER
159        .iter()
160        .filter(|c| classes.contains(c))
161        .map(|c| c.token())
162        .collect::<Vec<_>>()
163        .join("+")
164}
165
166impl Audience {
167    /// The disclosure classes this audience may see, in Annex XIII order.
168    #[must_use]
169    pub fn disclosure_set(self) -> Vec<Disclosure> {
170        DISCLOSURE_ORDER
171            .iter()
172            .copied()
173            .filter(|d| self.may_see(*d))
174            .collect()
175    }
176
177    /// The [`disclosure_key`] for this audience's classes — the name under which
178    /// a view served to it is signed and audited.
179    ///
180    /// Two audiences with the same class set would share a key, and that is
181    /// correct: the artefact describes the data it covers, not who asked.
182    #[must_use]
183    pub fn disclosure_key(self) -> String {
184        disclosure_key(&self.disclosure_set())
185    }
186
187    /// Whether this audience may see a field of class `disclosure`.
188    ///
189    /// The whole Art. 77(2) assignment, in one table.
190    #[must_use]
191    pub const fn may_see(self, disclosure: Disclosure) -> bool {
192        matches!(
193            (self, disclosure),
194            (Self::Public, Disclosure::Public)
195                | (
196                    Self::LegitimateInterest,
197                    Disclosure::Public | Disclosure::Restricted | Disclosure::Individual
198                )
199                | (
200                    Self::Authority,
201                    Disclosure::Public | Disclosure::Restricted | Disclosure::Conformity
202                )
203        )
204    }
205}
206
207/// A W3C Verifiable Credential 2.0 envelope binding a DPP passport to its signed payload.
208///
209/// The cryptographic proof is in [`SignedCredential::jws`]; this struct provides
210/// the structured VC context required for EUDI/EBSI interoperability.
211#[derive(Debug, Clone, Serialize, Deserialize)]
212#[serde(rename_all = "camelCase")]
213pub struct PassportCredential {
214    #[serde(rename = "@context")]
215    pub context: Vec<Value>,
216    #[serde(rename = "type")]
217    pub credential_type: Vec<String>,
218    /// Unique credential ID (`urn:uuid:…`) — generated fresh per signing call.
219    pub id: String,
220    /// DID of the signing issuer (`did:web:…`).
221    pub issuer: String,
222    /// Credential issuance timestamp (W3C VC 2.0 `validFrom`).
223    pub valid_from: DateTime<Utc>,
224    pub credential_subject: PassportCredentialSubject,
225}
226
227impl PassportCredential {
228    /// W3C VCDM v2 base context — MUST be the first `@context` entry.
229    pub const VC_BASE_CONTEXT: &'static str = "https://www.w3.org/ns/credentials/v2";
230
231    /// Inline JSON-LD term map for the DPP-specific terms this credential adds
232    /// on top of the VCDM v2 base context: the credential type value and the
233    /// one custom subject property (`payloadHash`).
234    ///
235    /// Inlined rather than hosted at a URL — a string entry in `@context` is
236    /// fetched by the consumer at expansion time, and this crate does not host
237    /// a context document. Same reasoning and `dpp:` prefix as
238    /// `dpp_vc::jsonld::context::passport_context`; a prefix IRI names a
239    /// vocabulary and is never dereferenced during expansion, so it carries no
240    /// such obligation.
241    fn dpp_terms() -> Value {
242        json!({
243            "dpp": "https://schema.odal-node.io/dpp#",
244            "DppPassportCredential": "dpp:DppPassportCredential",
245            "payloadHash": "dpp:payloadHash",
246        })
247    }
248
249    /// Construct a passport credential with the VCDM v2 base context and the
250    /// `VerifiableCredential` base type guaranteed present, so a caller cannot
251    /// emit a VC missing `https://www.w3.org/ns/credentials/v2`. `id`
252    /// (`urn:uuid:` v7) and `valid_from` are generated fresh.
253    #[must_use]
254    pub fn new(issuer: String, credential_subject: PassportCredentialSubject) -> Self {
255        Self {
256            context: vec![json!(Self::VC_BASE_CONTEXT), Self::dpp_terms()],
257            credential_type: vec![
258                "VerifiableCredential".into(),
259                "DppPassportCredential".into(),
260            ],
261            id: format!("urn:uuid:{}", uuid::Uuid::now_v7()),
262            issuer,
263            valid_from: Utc::now(),
264            credential_subject,
265        }
266    }
267}
268
269/// Claims about the DPP passport being attested.
270#[derive(Debug, Clone, Serialize, Deserialize)]
271#[serde(rename_all = "camelCase")]
272pub struct PassportCredentialSubject {
273    /// `urn:uuid:{passport_id}` — the DPP passport being attested.
274    pub id: String,
275    /// SHA-256 hex digest of the RFC 8785 canonical payload bytes.
276    pub payload_hash: String,
277}
278
279/// A DPP Verifiable Credential with its JWS proof signature.
280#[derive(Debug, Clone, Serialize, Deserialize)]
281#[serde(rename_all = "camelCase")]
282pub struct SignedCredential {
283    /// Structured W3C VC 2.0 passport credential.
284    pub credential: PassportCredential,
285    /// Compact JWS signature string (header.payload.signature).
286    pub jws: String,
287    /// The DID of the issuer (manufacturer or Odal on their behalf).
288    pub issuer_did: String,
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn authorities_do_not_see_individual_item_data() {
297        // Art. 77(2)(b) assigns notified bodies, market surveillance and the
298        // Commission Annex XIII points 2 and 3 — not point 4. This is the case
299        // an ordinal tier cannot express, and the reason the model changed.
300        assert!(!Audience::Authority.may_see(Disclosure::Individual));
301        assert!(Audience::LegitimateInterest.may_see(Disclosure::Individual));
302    }
303
304    #[test]
305    fn legitimate_interest_does_not_see_conformity_evidence() {
306        // Point 3 (test reports) is authority-only under Art. 77(2)(b).
307        assert!(!Audience::LegitimateInterest.may_see(Disclosure::Conformity));
308        assert!(Audience::Authority.may_see(Disclosure::Conformity));
309    }
310
311    /// The key names the *data*, not the asker. This is the property that lets a
312    /// stored signature survive ESPR naming a different actor taxonomy, so it is
313    /// asserted literally rather than round-tripped.
314    #[test]
315    fn a_disclosure_key_names_classes_never_an_audience() {
316        assert_eq!(Audience::Public.disclosure_key(), "public");
317        assert_eq!(
318            Audience::LegitimateInterest.disclosure_key(),
319            "public+restricted+individual"
320        );
321        assert_eq!(
322            Audience::Authority.disclosure_key(),
323            "public+restricted+conformity"
324        );
325
326        for audience in [
327            Audience::Public,
328            Audience::LegitimateInterest,
329            Audience::Authority,
330        ] {
331            let key = audience.disclosure_key();
332            for name in ["legitimate", "authority", "public_", "audience"] {
333                assert!(
334                    !key.contains(name) || key == "public",
335                    "{key} leaks an audience name into a durable artefact key"
336                );
337            }
338        }
339    }
340
341    /// Key construction is order-independent in its input and fixed in its
342    /// output: two nodes handed the same set in different orders must produce
343    /// byte-identical keys, or the same view signs under two names.
344    #[test]
345    fn a_disclosure_key_is_canonical_regardless_of_input_order() {
346        let forward = disclosure_key(&[
347            Disclosure::Public,
348            Disclosure::Restricted,
349            Disclosure::Individual,
350        ]);
351        let reversed = disclosure_key(&[
352            Disclosure::Individual,
353            Disclosure::Restricted,
354            Disclosure::Public,
355        ]);
356        assert_eq!(forward, reversed);
357        assert_eq!(forward, "public+restricted+individual");
358        assert_eq!(disclosure_key(&[]), "");
359    }
360
361    /// The set an audience is keyed by must be exactly what `may_see` grants —
362    /// if these drift, a view is signed under a key that overstates or
363    /// understates what it contains.
364    #[test]
365    fn the_disclosure_set_agrees_with_may_see() {
366        for audience in [
367            Audience::Public,
368            Audience::LegitimateInterest,
369            Audience::Authority,
370        ] {
371            let set = audience.disclosure_set();
372            for class in [
373                Disclosure::Public,
374                Disclosure::Restricted,
375                Disclosure::Conformity,
376                Disclosure::Individual,
377            ] {
378                assert_eq!(
379                    set.contains(&class),
380                    audience.may_see(class),
381                    "{audience:?} / {class:?}: disclosure_set disagrees with may_see"
382                );
383            }
384        }
385    }
386
387    #[test]
388    fn neither_non_public_audience_contains_the_other() {
389        // The defining property of a lattice: if either audience were a superset
390        // of the other, an ordinal ranking would suffice and this type would be
391        // unnecessary. Each sees something the other does not.
392        let all = [
393            Disclosure::Public,
394            Disclosure::Restricted,
395            Disclosure::Conformity,
396            Disclosure::Individual,
397        ];
398        let authority_only = all
399            .iter()
400            .any(|d| Audience::Authority.may_see(*d) && !Audience::LegitimateInterest.may_see(*d));
401        let interest_only = all
402            .iter()
403            .any(|d| Audience::LegitimateInterest.may_see(*d) && !Audience::Authority.may_see(*d));
404        assert!(authority_only && interest_only);
405    }
406
407    #[test]
408    fn point_two_is_shared_and_public_is_universal() {
409        for audience in [
410            Audience::Public,
411            Audience::LegitimateInterest,
412            Audience::Authority,
413        ] {
414            assert!(audience.may_see(Disclosure::Public));
415        }
416        // Annex XIII point 2 goes to both non-public audiences.
417        assert!(Audience::LegitimateInterest.may_see(Disclosure::Restricted));
418        assert!(Audience::Authority.may_see(Disclosure::Restricted));
419        assert!(!Audience::Public.may_see(Disclosure::Restricted));
420    }
421
422    #[test]
423    fn public_sees_nothing_restricted() {
424        for class in [
425            Disclosure::Restricted,
426            Disclosure::Conformity,
427            Disclosure::Individual,
428        ] {
429            assert!(!Audience::Public.may_see(class));
430        }
431    }
432
433    #[test]
434    fn new_passport_credential_guarantees_vc_base_context_and_type() {
435        let vc = PassportCredential::new(
436            "did:web:issuer.example.com".into(),
437            PassportCredentialSubject {
438                id: "urn:uuid:00000000-0000-0000-0000-000000000000".into(),
439                payload_hash: "deadbeef".into(),
440            },
441        );
442        // VCDM v2 requires the base context to be the first @context entry.
443        assert_eq!(
444            vc.context.first().and_then(Value::as_str),
445            Some(PassportCredential::VC_BASE_CONTEXT)
446        );
447        assert!(
448            vc.credential_type
449                .contains(&"VerifiableCredential".to_string())
450        );
451        assert!(vc.id.starts_with("urn:uuid:"));
452    }
453}