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}