use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Audience {
Public,
LegitimateInterest,
Authority,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Disclosure {
Public,
Restricted,
Conformity,
Individual,
}
pub const PASSPORT_FIELD_DISCLOSURE: &[(&str, Disclosure)] = &[
("batchId", Disclosure::Restricted),
("lintResult", Disclosure::Restricted),
("jwsSignature", Disclosure::Conformity),
("retentionLocked", Disclosure::Conformity),
];
impl Disclosure {
#[must_use]
pub const fn token(self) -> &'static str {
match self {
Self::Public => "public",
Self::Restricted => "restricted",
Self::Conformity => "conformity",
Self::Individual => "individual",
}
}
}
const DISCLOSURE_ORDER: &[Disclosure] = &[
Disclosure::Public,
Disclosure::Restricted,
Disclosure::Conformity,
Disclosure::Individual,
];
#[must_use]
pub fn disclosure_key(classes: &[Disclosure]) -> String {
DISCLOSURE_ORDER
.iter()
.filter(|c| classes.contains(c))
.map(|c| c.token())
.collect::<Vec<_>>()
.join("+")
}
impl Audience {
#[must_use]
pub fn disclosure_set(self) -> Vec<Disclosure> {
DISCLOSURE_ORDER
.iter()
.copied()
.filter(|d| self.may_see(*d))
.collect()
}
#[must_use]
pub fn disclosure_key(self) -> String {
disclosure_key(&self.disclosure_set())
}
#[must_use]
pub const fn may_see(self, disclosure: Disclosure) -> bool {
matches!(
(self, disclosure),
(Self::Public, Disclosure::Public)
| (
Self::LegitimateInterest,
Disclosure::Public | Disclosure::Restricted | Disclosure::Individual
)
| (
Self::Authority,
Disclosure::Public | Disclosure::Restricted | Disclosure::Conformity
)
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PassportCredential {
#[serde(rename = "@context")]
pub context: Vec<String>,
#[serde(rename = "type")]
pub credential_type: Vec<String>,
pub id: String,
pub issuer: String,
pub valid_from: DateTime<Utc>,
pub credential_subject: PassportCredentialSubject,
}
impl PassportCredential {
pub const VC_BASE_CONTEXT: &'static str = "https://www.w3.org/ns/credentials/v2";
pub const PASSPORT_CONTEXT: &'static str =
"https://schema.odal-node.io/credentials/dpp-passport/v1";
#[must_use]
pub fn new(issuer: String, credential_subject: PassportCredentialSubject) -> Self {
Self {
context: vec![Self::VC_BASE_CONTEXT.into(), Self::PASSPORT_CONTEXT.into()],
credential_type: vec![
"VerifiableCredential".into(),
"DppPassportCredential".into(),
],
id: format!("urn:uuid:{}", uuid::Uuid::now_v7()),
issuer,
valid_from: Utc::now(),
credential_subject,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PassportCredentialSubject {
pub id: String,
pub payload_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SignedCredential {
pub credential: PassportCredential,
pub jws: String,
pub issuer_did: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn authorities_do_not_see_individual_item_data() {
assert!(!Audience::Authority.may_see(Disclosure::Individual));
assert!(Audience::LegitimateInterest.may_see(Disclosure::Individual));
}
#[test]
fn legitimate_interest_does_not_see_conformity_evidence() {
assert!(!Audience::LegitimateInterest.may_see(Disclosure::Conformity));
assert!(Audience::Authority.may_see(Disclosure::Conformity));
}
#[test]
fn a_disclosure_key_names_classes_never_an_audience() {
assert_eq!(Audience::Public.disclosure_key(), "public");
assert_eq!(
Audience::LegitimateInterest.disclosure_key(),
"public+restricted+individual"
);
assert_eq!(
Audience::Authority.disclosure_key(),
"public+restricted+conformity"
);
for audience in [
Audience::Public,
Audience::LegitimateInterest,
Audience::Authority,
] {
let key = audience.disclosure_key();
for name in ["legitimate", "authority", "public_", "audience"] {
assert!(
!key.contains(name) || key == "public",
"{key} leaks an audience name into a durable artefact key"
);
}
}
}
#[test]
fn a_disclosure_key_is_canonical_regardless_of_input_order() {
let forward = disclosure_key(&[
Disclosure::Public,
Disclosure::Restricted,
Disclosure::Individual,
]);
let reversed = disclosure_key(&[
Disclosure::Individual,
Disclosure::Restricted,
Disclosure::Public,
]);
assert_eq!(forward, reversed);
assert_eq!(forward, "public+restricted+individual");
assert_eq!(disclosure_key(&[]), "");
}
#[test]
fn the_disclosure_set_agrees_with_may_see() {
for audience in [
Audience::Public,
Audience::LegitimateInterest,
Audience::Authority,
] {
let set = audience.disclosure_set();
for class in [
Disclosure::Public,
Disclosure::Restricted,
Disclosure::Conformity,
Disclosure::Individual,
] {
assert_eq!(
set.contains(&class),
audience.may_see(class),
"{audience:?} / {class:?}: disclosure_set disagrees with may_see"
);
}
}
}
#[test]
fn neither_non_public_audience_contains_the_other() {
let all = [
Disclosure::Public,
Disclosure::Restricted,
Disclosure::Conformity,
Disclosure::Individual,
];
let authority_only = all
.iter()
.any(|d| Audience::Authority.may_see(*d) && !Audience::LegitimateInterest.may_see(*d));
let interest_only = all
.iter()
.any(|d| Audience::LegitimateInterest.may_see(*d) && !Audience::Authority.may_see(*d));
assert!(authority_only && interest_only);
}
#[test]
fn point_two_is_shared_and_public_is_universal() {
for audience in [
Audience::Public,
Audience::LegitimateInterest,
Audience::Authority,
] {
assert!(audience.may_see(Disclosure::Public));
}
assert!(Audience::LegitimateInterest.may_see(Disclosure::Restricted));
assert!(Audience::Authority.may_see(Disclosure::Restricted));
assert!(!Audience::Public.may_see(Disclosure::Restricted));
}
#[test]
fn public_sees_nothing_restricted() {
for class in [
Disclosure::Restricted,
Disclosure::Conformity,
Disclosure::Individual,
] {
assert!(!Audience::Public.may_see(class));
}
}
#[test]
fn new_passport_credential_guarantees_vc_base_context_and_type() {
let vc = PassportCredential::new(
"did:web:issuer.example.com".into(),
PassportCredentialSubject {
id: "urn:uuid:00000000-0000-0000-0000-000000000000".into(),
payload_hash: "deadbeef".into(),
},
);
assert_eq!(
vc.context.first().map(String::as_str),
Some(PassportCredential::VC_BASE_CONTEXT)
);
assert!(
vc.credential_type
.contains(&"VerifiableCredential".to_string())
);
assert!(vc.id.starts_with("urn:uuid:"));
}
}