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 Audience {
79 /// Whether this audience may see a field of class `disclosure`.
80 ///
81 /// The whole Art. 77(2) assignment, in one table.
82 #[must_use]
83 pub const fn may_see(self, disclosure: Disclosure) -> bool {
84 matches!(
85 (self, disclosure),
86 (Self::Public, Disclosure::Public)
87 | (
88 Self::LegitimateInterest,
89 Disclosure::Public | Disclosure::Restricted | Disclosure::Individual
90 )
91 | (
92 Self::Authority,
93 Disclosure::Public | Disclosure::Restricted | Disclosure::Conformity
94 )
95 )
96 }
97}
98
99/// A W3C Verifiable Credential 2.0 envelope binding a DPP passport to its signed payload.
100///
101/// The cryptographic proof is in [`SignedCredential::jws`]; this struct provides
102/// the structured VC context required for EUDI/EBSI interoperability.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct PassportCredential {
106 #[serde(rename = "@context")]
107 pub context: Vec<String>,
108 #[serde(rename = "type")]
109 pub credential_type: Vec<String>,
110 /// Unique credential ID (`urn:uuid:…`) — generated fresh per signing call.
111 pub id: String,
112 /// DID of the signing issuer (`did:web:…`).
113 pub issuer: String,
114 /// Credential issuance timestamp (W3C VC 2.0 `validFrom`).
115 pub valid_from: DateTime<Utc>,
116 pub credential_subject: PassportCredentialSubject,
117}
118
119impl PassportCredential {
120 /// W3C VCDM v2 base context — MUST be the first `@context` entry.
121 pub const VC_BASE_CONTEXT: &'static str = "https://www.w3.org/ns/credentials/v2";
122 /// Project-specific JSON-LD context for DPP passport credentials.
123 pub const PASSPORT_CONTEXT: &'static str =
124 "https://schema.odal-node.io/credentials/dpp-passport/v1";
125
126 /// Construct a passport credential with the VCDM v2 base context and the
127 /// `VerifiableCredential` base type guaranteed present, so a caller cannot
128 /// emit a VC missing `https://www.w3.org/ns/credentials/v2`. `id`
129 /// (`urn:uuid:` v7) and `valid_from` are generated fresh.
130 #[must_use]
131 pub fn new(issuer: String, credential_subject: PassportCredentialSubject) -> Self {
132 Self {
133 context: vec![Self::VC_BASE_CONTEXT.into(), Self::PASSPORT_CONTEXT.into()],
134 credential_type: vec![
135 "VerifiableCredential".into(),
136 "DppPassportCredential".into(),
137 ],
138 id: format!("urn:uuid:{}", uuid::Uuid::now_v7()),
139 issuer,
140 valid_from: Utc::now(),
141 credential_subject,
142 }
143 }
144}
145
146/// Claims about the DPP passport being attested.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148#[serde(rename_all = "camelCase")]
149pub struct PassportCredentialSubject {
150 /// `urn:uuid:{passport_id}` — the DPP passport being attested.
151 pub id: String,
152 /// SHA-256 hex digest of the RFC 8785 canonical payload bytes.
153 pub payload_hash: String,
154}
155
156/// A DPP Verifiable Credential with its JWS proof signature.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158#[serde(rename_all = "camelCase")]
159pub struct SignedCredential {
160 /// Structured W3C VC 2.0 passport credential.
161 pub credential: PassportCredential,
162 /// Compact JWS signature string (header.payload.signature).
163 pub jws: String,
164 /// The DID of the issuer (manufacturer or Odal on their behalf).
165 pub issuer_did: String,
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn authorities_do_not_see_individual_item_data() {
174 // Art. 77(2)(b) assigns notified bodies, market surveillance and the
175 // Commission Annex XIII points 2 and 3 — not point 4. This is the case
176 // an ordinal tier cannot express, and the reason the model changed.
177 assert!(!Audience::Authority.may_see(Disclosure::Individual));
178 assert!(Audience::LegitimateInterest.may_see(Disclosure::Individual));
179 }
180
181 #[test]
182 fn legitimate_interest_does_not_see_conformity_evidence() {
183 // Point 3 (test reports) is authority-only under Art. 77(2)(b).
184 assert!(!Audience::LegitimateInterest.may_see(Disclosure::Conformity));
185 assert!(Audience::Authority.may_see(Disclosure::Conformity));
186 }
187
188 #[test]
189 fn neither_non_public_audience_contains_the_other() {
190 // The defining property of a lattice: if either audience were a superset
191 // of the other, an ordinal ranking would suffice and this type would be
192 // unnecessary. Each sees something the other does not.
193 let all = [
194 Disclosure::Public,
195 Disclosure::Restricted,
196 Disclosure::Conformity,
197 Disclosure::Individual,
198 ];
199 let authority_only = all
200 .iter()
201 .any(|d| Audience::Authority.may_see(*d) && !Audience::LegitimateInterest.may_see(*d));
202 let interest_only = all
203 .iter()
204 .any(|d| Audience::LegitimateInterest.may_see(*d) && !Audience::Authority.may_see(*d));
205 assert!(authority_only && interest_only);
206 }
207
208 #[test]
209 fn point_two_is_shared_and_public_is_universal() {
210 for audience in [
211 Audience::Public,
212 Audience::LegitimateInterest,
213 Audience::Authority,
214 ] {
215 assert!(audience.may_see(Disclosure::Public));
216 }
217 // Annex XIII point 2 goes to both non-public audiences.
218 assert!(Audience::LegitimateInterest.may_see(Disclosure::Restricted));
219 assert!(Audience::Authority.may_see(Disclosure::Restricted));
220 assert!(!Audience::Public.may_see(Disclosure::Restricted));
221 }
222
223 #[test]
224 fn public_sees_nothing_restricted() {
225 for class in [
226 Disclosure::Restricted,
227 Disclosure::Conformity,
228 Disclosure::Individual,
229 ] {
230 assert!(!Audience::Public.may_see(class));
231 }
232 }
233
234 #[test]
235 fn new_passport_credential_guarantees_vc_base_context_and_type() {
236 let vc = PassportCredential::new(
237 "did:web:issuer.example.com".into(),
238 PassportCredentialSubject {
239 id: "urn:uuid:00000000-0000-0000-0000-000000000000".into(),
240 payload_hash: "deadbeef".into(),
241 },
242 );
243 // VCDM v2 requires the base context to be the first @context entry.
244 assert_eq!(
245 vc.context.first().map(String::as_str),
246 Some(PassportCredential::VC_BASE_CONTEXT)
247 );
248 assert!(
249 vc.credential_type
250 .contains(&"VerifiableCredential".to_string())
251 );
252 assert!(vc.id.starts_with("urn:uuid:"));
253 }
254}