use serde_json::{Map, Value, json};
use super::builder::{SdJwt, build_payload, conceal};
use super::disclosure::{Disclosure, digest_of};
use super::error::{DisclosureError, SdJwtError};
fn stub_jwt(payload: &Value) -> String {
use base64::Engine;
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD;
format!(
"{}.{}.{}",
b64.encode(br#"{"alg":"EdDSA","typ":"dc+sd-jwt"}"#),
b64.encode(payload.to_string()),
b64.encode([0u8; 64])
)
}
pub(super) fn sample() -> Map<String, Value> {
json!({
"batteryChemistry": "LFP",
"cathodeMaterial": ["LiFePO4"],
"stateOfHealthPct": 87.5,
"testReportResults": "report-4471",
"safetyMeasures": ["Do not puncture"],
})
.as_object()
.unwrap()
.clone()
}
pub(super) const HIDDEN: [&str; 4] = [
"cathodeMaterial",
"stateOfHealthPct",
"testReportResults",
"safetyMeasures",
];
fn issue() -> SdJwt {
let (concealed, disclosures) = conceal(&sample(), |name| HIDDEN.contains(&name)).unwrap();
let payload = build_payload(concealed, !disclosures.is_empty());
SdJwt::new(stub_jwt(&payload), disclosures)
}
#[test]
fn concealed_claims_leave_no_cleartext_in_the_payload() {
let (concealed, disclosures) = conceal(&sample(), |name| HIDDEN.contains(&name)).unwrap();
assert_eq!(disclosures.len(), 4);
assert!(concealed.contains_key("batteryChemistry"));
for name in HIDDEN {
assert!(!concealed.contains_key(name), "{name} left in cleartext");
}
let digests = concealed["_sd"].as_array().unwrap();
assert_eq!(digests.len(), 4);
}
#[test]
fn a_full_credential_round_trips_through_the_combined_format() {
let issued = issue();
let parsed = SdJwt::parse(&issued.serialise()).unwrap();
assert_eq!(parsed.disclosures().len(), 4);
assert!(!parsed.has_key_binding());
let payload = parsed.disclosed_payload().unwrap();
assert_eq!(payload["batteryChemistry"], json!("LFP"));
assert_eq!(payload["cathodeMaterial"], json!(["LiFePO4"]));
assert_eq!(payload["stateOfHealthPct"], json!(87.5));
assert!(!payload.contains_key("_sd"), "_sd survived processing");
assert!(!payload.contains_key("_sd_alg"), "_sd_alg survived");
}
#[test]
fn a_two_of_four_presentation_does_not_carry_the_withheld_two() {
use base64::Engine;
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD;
let issued = issue();
let reveal: Vec<String> = ["cathodeMaterial", "stateOfHealthPct"]
.iter()
.flat_map(|n| issued.digests_for_claim(n))
.collect();
let reveal: Vec<&str> = reveal.iter().map(String::as_str).collect();
let presentation = issued.present(&reveal);
let wire = presentation.serialise();
assert_eq!(presentation.disclosures().len(), 2);
let payload = presentation.disclosed_payload().unwrap();
assert_eq!(payload["cathodeMaterial"], json!(["LiFePO4"]));
assert_eq!(payload["stateOfHealthPct"], json!(87.5));
assert!(!payload.contains_key("testReportResults"));
assert!(!payload.contains_key("safetyMeasures"));
let decoded: String = wire
.split('~')
.flat_map(|s| s.split('.'))
.filter_map(|s| b64.decode(s).ok())
.map(|b| String::from_utf8_lossy(&b).into_owned())
.collect::<Vec<_>>()
.join("|");
for needle in [
"report-4471",
"Do not puncture",
"testReportResults",
"safetyMeasures",
] {
assert!(!wire.contains(needle), "{needle} is in the presentation");
assert!(
!decoded.contains(needle),
"{needle} decodes out of a segment"
);
}
assert_eq!(presentation.jwt(), issued.jwt());
}
#[test]
fn a_tampered_disclosure_is_refused_not_ignored() {
let issued = issue();
let original = &issued.disclosures()[0];
let forged = Disclosure::with_salt(
original.salt().to_owned(),
original.claim_name(),
json!("forged"),
)
.unwrap();
assert_ne!(forged.digest(), original.digest());
let tampered = SdJwt::new(issued.jwt().to_owned(), vec![forged]);
assert_eq!(
tampered.disclosed_payload(),
Err(SdJwtError::UnusedDisclosures(1))
);
}
#[test]
fn a_disclosure_naming_a_cleartext_claim_is_refused() {
let issued = issue();
let collides = Disclosure::new("batteryChemistry", json!("NMC")).unwrap();
let payload = json!({
"batteryChemistry": "LFP",
"_sd": [collides.digest()],
"_sd_alg": "sha-256",
});
let sd = SdJwt::new(stub_jwt(&payload), vec![collides]);
assert_eq!(
sd.disclosed_payload(),
Err(SdJwtError::ClaimCollision("batteryChemistry".to_owned()))
);
let _ = issued;
}
#[test]
fn a_present_but_unsupported_sd_alg_is_refused_rather_than_assumed() {
let payload = json!({ "_sd": [], "_sd_alg": "sha-512" });
let sd = SdJwt::new(stub_jwt(&payload), vec![]);
assert_eq!(
sd.disclosed_payload(),
Err(SdJwtError::UnsupportedHashAlg("sha-512".to_owned()))
);
}
#[test]
fn an_absent_sd_alg_means_sha_256() {
let d = Disclosure::new("x", json!(1)).unwrap();
let payload = json!({ "_sd": [d.digest()] });
let sd = SdJwt::new(stub_jwt(&payload), vec![d]);
assert_eq!(sd.disclosed_payload().unwrap()["x"], json!(1));
}
#[test]
fn nothing_concealed_means_no_sd_claim() {
let (concealed, disclosures) = conceal(&sample(), |_| false).unwrap();
assert!(disclosures.is_empty());
assert!(!concealed.contains_key("_sd"));
let payload = build_payload(concealed, false);
assert!(payload.get("_sd_alg").is_none());
}
#[test]
fn a_disclosure_survives_a_parse_and_reencode_unchanged() {
let d = Disclosure::new("cathodeMaterial", json!(["LiFePO4"])).unwrap();
let back = Disclosure::parse(d.encoded()).unwrap();
assert_eq!(back, d);
assert_eq!(back.digest(), d.digest());
}
#[test]
fn reserved_claim_names_are_refused() {
use base64::Engine;
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD;
for name in ["_sd", "..."] {
let encoded = b64.encode(json!(["salt", name, 1]).to_string());
assert_eq!(
Disclosure::parse(&encoded),
Err(DisclosureError::ReservedClaimName)
);
}
}
#[test]
fn a_two_element_array_disclosure_is_refused() {
use base64::Engine;
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD;
let encoded = b64.encode(json!(["salt", "value"]).to_string());
assert_eq!(
Disclosure::parse(&encoded),
Err(DisclosureError::NotATriple)
);
}
#[test]
fn a_token_without_a_trailing_separator_is_not_an_sd_jwt() {
assert!(matches!(
SdJwt::parse("onlyonesegment"),
Err(SdJwtError::NotAnSdJwt)
));
}
#[test]
fn a_key_binding_segment_is_seen_and_not_read_as_a_disclosure() {
let issued = issue();
let with_kb = format!("{}kb-jwt-placeholder", issued.serialise());
let parsed = SdJwt::parse(&with_kb).unwrap();
assert_eq!(parsed.disclosures().len(), 4);
assert!(parsed.has_key_binding());
}
#[test]
fn digest_of_matches_the_disclosures_own_digest() {
let d = Disclosure::new("x", json!("y")).unwrap();
assert_eq!(digest_of(d.encoded()), d.digest());
}
#[test]
fn revealing_one_of_two_same_named_claims_withholds_the_other() {
let outer = json!({ "serialNumber": "OUTER" })
.as_object()
.unwrap()
.clone();
let inner = json!({ "serialNumber": "INNER" })
.as_object()
.unwrap()
.clone();
let (mut kept, mut disclosures) = conceal(&outer, |n| n == "serialNumber").unwrap();
let (inner_kept, mut inner_disclosures) = conceal(&inner, |n| n == "serialNumber").unwrap();
kept.insert("nested".to_owned(), Value::Object(inner_kept));
disclosures.append(&mut inner_disclosures);
let sd = SdJwt::new(stub_jwt(&build_payload(kept, true)), disclosures);
assert_eq!(
sd.digests_for_claim("serialNumber").len(),
2,
"fixture must be genuinely ambiguous or this proves nothing"
);
let outer_digest = sd.disclosures()[0].digest();
let presentation = sd.present(&[outer_digest.as_str()]);
assert_eq!(presentation.disclosures().len(), 1);
let wire = presentation.serialise();
assert!(wire.contains(sd.disclosures()[0].encoded()));
assert!(
!wire.contains(sd.disclosures()[1].encoded()),
"the same-named sibling rode along — selection is matching on name"
);
}
#[test]
fn the_same_disclosure_supplied_twice_is_refused() {
let d = Disclosure::new("x", json!(1)).unwrap();
let payload = json!({ "_sd": [d.digest()], "_sd_alg": "sha-256" });
let sd = SdJwt::new(stub_jwt(&payload), vec![d.clone(), d.clone()]);
assert_eq!(
sd.disclosed_payload(),
Err(SdJwtError::DuplicateDigest(d.digest()))
);
}
#[test]
fn a_digest_repeated_across_two_sd_arrays_is_refused() {
let orphan = Disclosure::new("ghost", json!("boo")).unwrap();
let payload = json!({
"_sd": [orphan.digest()],
"_sd_alg": "sha-256",
"nested": { "_sd": [orphan.digest()] },
});
let sd = SdJwt::new(stub_jwt(&payload), vec![]);
assert_eq!(
sd.disclosed_payload(),
Err(SdJwtError::DuplicateDigest(orphan.digest()))
);
}
#[test]
fn constructors_refuse_reserved_claim_names() {
for name in ["_sd", "..."] {
assert_eq!(
Disclosure::new(name, json!(1)),
Err(DisclosureError::ReservedClaimName)
);
assert_eq!(
Disclosure::with_salt("salt".to_owned(), name, json!(1)),
Err(DisclosureError::ReservedClaimName)
);
}
}
#[test]
fn every_constructed_disclosure_round_trips() {
for (name, value) in [
("plain", json!("v")),
("nested", json!({"a": [1, 2, {"b": null}]})),
("unicode", json!("ü \" \\ 🔐")),
] {
let d = Disclosure::new(name, value).unwrap();
assert_eq!(Disclosure::parse(d.encoded()).unwrap(), d);
}
}