dpp-domain 0.16.0

EU Digital Product Passport domain types, port traits, and per-field disclosure policy
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
//! Identity and access types: `Audience`, `Disclosure`, `SignedCredential`, and `PassportCredential`.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};

/// Who is asking for passport data.
///
/// Regulation (EU) 2023/1542 Art. 77(2) names three audiences and assigns each
/// a set of Annex XIII data points:
///
/// | Audience | Annex XIII |
/// |---|---|
/// | (a) general public | 1 |
/// | (b) notified bodies, market surveillance authorities, the Commission | 2 and 3 |
/// | (c) persons with a legitimate interest | 2 and 4 |
///
/// **This is a lattice, not a ranking.** Point 3 (conformity test reports) is
/// authority-only; point 4 (individual-item use history) is
/// legitimate-interest-only. Neither audience contains the other, so no integer
/// ordering can express the assignment: any `>=` comparison necessarily either
/// hands authorities the individual-item data Art. 77(2)(b) withholds, or hides
/// point-2 data from someone entitled to it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Audience {
    /// Anyone, with no credential. Art. 77(2)(a).
    Public,
    /// A repairer, remanufacturer, second-life operator or recycler holding a
    /// credential that proves the interest. Art. 77(2)(c).
    LegitimateInterest,
    /// Notified body, market surveillance authority, customs, or the
    /// Commission. Art. 77(2)(b).
    Authority,
}

/// How restricted a field is — the counterpart to [`Audience`].
///
/// Named for the Annex XIII point each class corresponds to, and kept
/// sector-agnostic so non-battery sectors reuse the same vocabulary.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Disclosure {
    /// Publicly accessible. Annex XIII point 1.
    Public,
    /// Detailed composition, dismantling information, safety measures.
    /// Annex XIII point 2 — visible to **both** non-public audiences.
    Restricted,
    /// Conformity evidence: results of test reports. Annex XIII point 3 —
    /// authorities only.
    Conformity,
    /// Information and data relating to an **individual** item: use history,
    /// cycle counts, negative events, state of health, status. Annex XIII
    /// point 4 — legitimate interest only, and explicitly **not** authorities.
    Individual,
}

/// Disclosure class of every top-level passport field that is not public.
///
/// **The single source for this fact.** `Passport::redact` and the crypto
/// layer's `SectorAccessPolicy::passport_default()` both read it, because they
/// previously each carried their own copy and drifted: the policy classified
/// `lintResult` as restricted while `redact` never removed it, so a public view
/// built through the domain path disclosed it.
///
/// Fields absent from this list are [`Disclosure::Public`].
pub const PASSPORT_FIELD_DISCLOSURE: &[(&str, Disclosure)] = &[
    ("batchId", Disclosure::Restricted),
    // Advisory plausibility output, re-computable after publish and carrying
    // free-text findings about our own data quality — operator- and
    // auditor-facing, not consumer-facing.
    ("lintResult", Disclosure::Restricted),
    ("jwsSignature", Disclosure::Conformity),
    ("retentionLocked", Disclosure::Conformity),
];

impl Disclosure {
    /// The stable wire token for this class, used to build a disclosure-set key.
    ///
    /// Deliberately not `Serialize`-derived: this string is baked into stored
    /// artefact keys, so it must be stable independently of any future serde
    /// attribute change on the enum.
    #[must_use]
    pub const fn token(self) -> &'static str {
        match self {
            Self::Public => "public",
            Self::Restricted => "restricted",
            Self::Conformity => "conformity",
            Self::Individual => "individual",
        }
    }
}

/// Every disclosure class, in the fixed order a [`disclosure_key`] uses.
///
/// Ordering is by Annex XIII point number, and it is part of the key format:
/// two nodes must produce byte-identical keys for the same set.
const DISCLOSURE_ORDER: &[Disclosure] = &[
    Disclosure::Public,
    Disclosure::Restricted,
    Disclosure::Conformity,
    Disclosure::Individual,
];

/// Name a set of disclosure classes: the classes' tokens in Annex XIII order,
/// joined with `+` — e.g. `public+restricted+individual`.
///
/// **This is how durable artefacts are keyed, and it must never be an audience
/// name.** ESPR uses a ~14-class actor vocabulary that is not battery's
/// three-audience lattice, and the delegated act mapping actors to data does not
/// exist yet. A signature or audit row keyed `"legitimateInterest"` would have
/// to be migrated the day that mapping lands; one keyed by the disclosure set it
/// actually covers keeps meaning exactly what it always meant, and a new actor
/// taxonomy becomes a new mapping onto the same keys.
#[must_use]
pub fn disclosure_key(classes: &[Disclosure]) -> String {
    DISCLOSURE_ORDER
        .iter()
        .filter(|c| classes.contains(c))
        .map(|c| c.token())
        .collect::<Vec<_>>()
        .join("+")
}

impl Audience {
    /// The disclosure classes this audience may see, in Annex XIII order.
    #[must_use]
    pub fn disclosure_set(self) -> Vec<Disclosure> {
        DISCLOSURE_ORDER
            .iter()
            .copied()
            .filter(|d| self.may_see(*d))
            .collect()
    }

    /// The [`disclosure_key`] for this audience's classes — the name under which
    /// a view served to it is signed and audited.
    ///
    /// Two audiences with the same class set would share a key, and that is
    /// correct: the artefact describes the data it covers, not who asked.
    #[must_use]
    pub fn disclosure_key(self) -> String {
        disclosure_key(&self.disclosure_set())
    }

    /// Whether this audience may see a field of class `disclosure`.
    ///
    /// The whole Art. 77(2) assignment, in one table.
    #[must_use]
    pub const fn may_see(self, disclosure: Disclosure) -> bool {
        matches!(
            (self, disclosure),
            (Self::Public, Disclosure::Public)
                | (
                    Self::LegitimateInterest,
                    Disclosure::Public | Disclosure::Restricted | Disclosure::Individual
                )
                | (
                    Self::Authority,
                    Disclosure::Public | Disclosure::Restricted | Disclosure::Conformity
                )
        )
    }
}

/// A W3C Verifiable Credential 2.0 envelope binding a DPP passport to its signed payload.
///
/// The cryptographic proof is in [`SignedCredential::jws`]; this struct provides
/// the structured VC context required for EUDI/EBSI interoperability.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PassportCredential {
    #[serde(rename = "@context")]
    pub context: Vec<Value>,
    #[serde(rename = "type")]
    pub credential_type: Vec<String>,
    /// Unique credential ID (`urn:uuid:…`) — generated fresh per signing call.
    pub id: String,
    /// DID of the signing issuer (`did:web:…`).
    pub issuer: String,
    /// Credential issuance timestamp (W3C VC 2.0 `validFrom`).
    pub valid_from: DateTime<Utc>,
    pub credential_subject: PassportCredentialSubject,
}

impl PassportCredential {
    /// W3C VCDM v2 base context — MUST be the first `@context` entry.
    pub const VC_BASE_CONTEXT: &'static str = "https://www.w3.org/ns/credentials/v2";

    /// Inline JSON-LD term map for the DPP-specific terms this credential adds
    /// on top of the VCDM v2 base context: the credential type value and the
    /// one custom subject property (`payloadHash`).
    ///
    /// Inlined rather than hosted at a URL — a string entry in `@context` is
    /// fetched by the consumer at expansion time, and this crate does not host
    /// a context document. Same reasoning and `dpp:` prefix as
    /// `dpp_vc::jsonld::context::passport_context`; a prefix IRI names a
    /// vocabulary and is never dereferenced during expansion, so it carries no
    /// such obligation.
    fn dpp_terms() -> Value {
        json!({
            "dpp": "https://schema.odal-node.io/dpp#",
            "DppPassportCredential": "dpp:DppPassportCredential",
            "payloadHash": "dpp:payloadHash",
        })
    }

    /// Construct a passport credential with the VCDM v2 base context and the
    /// `VerifiableCredential` base type guaranteed present, so a caller cannot
    /// emit a VC missing `https://www.w3.org/ns/credentials/v2`. `id`
    /// (`urn:uuid:` v7) and `valid_from` are generated fresh.
    #[must_use]
    pub fn new(issuer: String, credential_subject: PassportCredentialSubject) -> Self {
        Self {
            context: vec![json!(Self::VC_BASE_CONTEXT), Self::dpp_terms()],
            credential_type: vec![
                "VerifiableCredential".into(),
                "DppPassportCredential".into(),
            ],
            id: format!("urn:uuid:{}", uuid::Uuid::now_v7()),
            issuer,
            valid_from: Utc::now(),
            credential_subject,
        }
    }
}

/// Claims about the DPP passport being attested.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PassportCredentialSubject {
    /// `urn:uuid:{passport_id}` — the DPP passport being attested.
    pub id: String,
    /// SHA-256 hex digest of the RFC 8785 canonical payload bytes.
    pub payload_hash: String,
}

/// A DPP Verifiable Credential with its JWS proof signature.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SignedCredential {
    /// Structured W3C VC 2.0 passport credential.
    pub credential: PassportCredential,
    /// Compact JWS signature string (header.payload.signature).
    pub jws: String,
    /// The DID of the issuer (manufacturer or Odal on their behalf).
    pub issuer_did: String,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn authorities_do_not_see_individual_item_data() {
        // Art. 77(2)(b) assigns notified bodies, market surveillance and the
        // Commission Annex XIII points 2 and 3 — not point 4. This is the case
        // an ordinal tier cannot express, and the reason the model changed.
        assert!(!Audience::Authority.may_see(Disclosure::Individual));
        assert!(Audience::LegitimateInterest.may_see(Disclosure::Individual));
    }

    #[test]
    fn legitimate_interest_does_not_see_conformity_evidence() {
        // Point 3 (test reports) is authority-only under Art. 77(2)(b).
        assert!(!Audience::LegitimateInterest.may_see(Disclosure::Conformity));
        assert!(Audience::Authority.may_see(Disclosure::Conformity));
    }

    /// The key names the *data*, not the asker. This is the property that lets a
    /// stored signature survive ESPR naming a different actor taxonomy, so it is
    /// asserted literally rather than round-tripped.
    #[test]
    fn a_disclosure_key_names_classes_never_an_audience() {
        assert_eq!(Audience::Public.disclosure_key(), "public");
        assert_eq!(
            Audience::LegitimateInterest.disclosure_key(),
            "public+restricted+individual"
        );
        assert_eq!(
            Audience::Authority.disclosure_key(),
            "public+restricted+conformity"
        );

        for audience in [
            Audience::Public,
            Audience::LegitimateInterest,
            Audience::Authority,
        ] {
            let key = audience.disclosure_key();
            for name in ["legitimate", "authority", "public_", "audience"] {
                assert!(
                    !key.contains(name) || key == "public",
                    "{key} leaks an audience name into a durable artefact key"
                );
            }
        }
    }

    /// Key construction is order-independent in its input and fixed in its
    /// output: two nodes handed the same set in different orders must produce
    /// byte-identical keys, or the same view signs under two names.
    #[test]
    fn a_disclosure_key_is_canonical_regardless_of_input_order() {
        let forward = disclosure_key(&[
            Disclosure::Public,
            Disclosure::Restricted,
            Disclosure::Individual,
        ]);
        let reversed = disclosure_key(&[
            Disclosure::Individual,
            Disclosure::Restricted,
            Disclosure::Public,
        ]);
        assert_eq!(forward, reversed);
        assert_eq!(forward, "public+restricted+individual");
        assert_eq!(disclosure_key(&[]), "");
    }

    /// The set an audience is keyed by must be exactly what `may_see` grants —
    /// if these drift, a view is signed under a key that overstates or
    /// understates what it contains.
    #[test]
    fn the_disclosure_set_agrees_with_may_see() {
        for audience in [
            Audience::Public,
            Audience::LegitimateInterest,
            Audience::Authority,
        ] {
            let set = audience.disclosure_set();
            for class in [
                Disclosure::Public,
                Disclosure::Restricted,
                Disclosure::Conformity,
                Disclosure::Individual,
            ] {
                assert_eq!(
                    set.contains(&class),
                    audience.may_see(class),
                    "{audience:?} / {class:?}: disclosure_set disagrees with may_see"
                );
            }
        }
    }

    #[test]
    fn neither_non_public_audience_contains_the_other() {
        // The defining property of a lattice: if either audience were a superset
        // of the other, an ordinal ranking would suffice and this type would be
        // unnecessary. Each sees something the other does not.
        let all = [
            Disclosure::Public,
            Disclosure::Restricted,
            Disclosure::Conformity,
            Disclosure::Individual,
        ];
        let authority_only = all
            .iter()
            .any(|d| Audience::Authority.may_see(*d) && !Audience::LegitimateInterest.may_see(*d));
        let interest_only = all
            .iter()
            .any(|d| Audience::LegitimateInterest.may_see(*d) && !Audience::Authority.may_see(*d));
        assert!(authority_only && interest_only);
    }

    #[test]
    fn point_two_is_shared_and_public_is_universal() {
        for audience in [
            Audience::Public,
            Audience::LegitimateInterest,
            Audience::Authority,
        ] {
            assert!(audience.may_see(Disclosure::Public));
        }
        // Annex XIII point 2 goes to both non-public audiences.
        assert!(Audience::LegitimateInterest.may_see(Disclosure::Restricted));
        assert!(Audience::Authority.may_see(Disclosure::Restricted));
        assert!(!Audience::Public.may_see(Disclosure::Restricted));
    }

    #[test]
    fn public_sees_nothing_restricted() {
        for class in [
            Disclosure::Restricted,
            Disclosure::Conformity,
            Disclosure::Individual,
        ] {
            assert!(!Audience::Public.may_see(class));
        }
    }

    #[test]
    fn new_passport_credential_guarantees_vc_base_context_and_type() {
        let vc = PassportCredential::new(
            "did:web:issuer.example.com".into(),
            PassportCredentialSubject {
                id: "urn:uuid:00000000-0000-0000-0000-000000000000".into(),
                payload_hash: "deadbeef".into(),
            },
        );
        // VCDM v2 requires the base context to be the first @context entry.
        assert_eq!(
            vc.context.first().and_then(Value::as_str),
            Some(PassportCredential::VC_BASE_CONTEXT)
        );
        assert!(
            vc.credential_type
                .contains(&"VerifiableCredential".to_string())
        );
        assert!(vc.id.starts_with("urn:uuid:"));
    }
}