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