Skip to main content

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}