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}