use chrono::{TimeZone, Utc};
use serde_json::json;
use dpp_domain::ProductGroup;
use super::test_fixtures::*;
use super::{SdJwtVcError, vct_for, verify};
use crate::test_support::temp_store;
#[test]
fn a_credential_without_exp_or_nbf_is_unbounded() {
let store = temp_store("sdjwtvc-unbounded", KEY_ID);
let credential = issued(&store).serialise();
assert!(check(&store, &credential, Utc.timestamp_opt(0, 0).unwrap()).is_ok());
assert!(
check(
&store,
&credential,
Utc.timestamp_opt(4_000_000_000, 0).unwrap()
)
.is_ok()
);
}
#[test]
fn an_expired_credential_is_refused() {
let store = temp_store("sdjwtvc-expired", KEY_ID);
let expiry = ISSUED_AT + 3600;
let credential = issued_with(&store, &[("exp", json!(expiry))]);
assert!(
check(
&store,
&credential,
Utc.timestamp_opt(expiry - 1, 0).unwrap()
)
.is_ok()
);
assert!(matches!(
check(&store, &credential, Utc.timestamp_opt(expiry, 0).unwrap()),
Err(SdJwtVcError::Expired { .. })
));
assert!(matches!(
check(
&store,
&credential,
Utc.timestamp_opt(expiry + 86_400, 0).unwrap()
),
Err(SdJwtVcError::Expired { .. })
));
}
#[test]
fn a_credential_presented_before_nbf_is_refused() {
let store = temp_store("sdjwtvc-nbf", KEY_ID);
let start = ISSUED_AT + 3600;
let credential = issued_with(&store, &[("nbf", json!(start))]);
assert!(matches!(
check(
&store,
&credential,
Utc.timestamp_opt(start - 1, 0).unwrap()
),
Err(SdJwtVcError::NotYetValid { .. })
));
assert!(check(&store, &credential, Utc.timestamp_opt(start, 0).unwrap()).is_ok());
}
#[test]
fn a_temporal_claim_that_is_not_a_numericdate_is_refused() {
let store = temp_store("sdjwtvc-malformed", KEY_ID);
for claim in ["nbf", "exp"] {
let credential = issued_with(&store, &[(claim, json!("2026-09-16T00:00:00Z"))]);
assert!(
matches!(
check(&store, &credential, now()),
Err(SdJwtVcError::MalformedTemporalClaim(_))
),
"{claim} as a string was accepted"
);
}
let credential = issued_with(&store, &[("iat", json!("not-a-date"))]);
let disclosed = verify(
&credential,
&public_key_b64(&store),
&vct_for(ProductGroup::Battery, SCHEMA_VERSION),
now(),
)
.expect("issuer-set iat is well-formed");
assert_eq!(disclosed["iat"], json!(ISSUED_AT));
}
#[test]
fn validity_controlling_claims_are_never_disclosable() {
let store = temp_store("sdjwtvc-critical", KEY_ID);
let credential = issued_with(
&store,
&[
("exp", json!(ISSUED_AT + 3600)),
("nbf", json!(ISSUED_AT - 10)),
("aud", json!("https://verifier.example")),
("cnf", json!({"jwk": {"kty": "OKP"}})),
],
);
let parsed = dpp_crypto::sd_jwt::SdJwt::parse(&credential).unwrap();
for claim in ["iss", "aud", "exp", "nbf", "cnf", "vct", "iat"] {
assert!(
parsed.digests_for_claim(claim).is_empty(),
"{claim} was issued as a disclosure, so a holder can withhold it"
);
}
}
#[test]
fn exp_is_not_selectively_disclosable() {
let store = temp_store("sdjwtvc-exp-cleartext", KEY_ID);
let expiry = ISSUED_AT + 3600;
let credential = issued_with(&store, &[("exp", json!(expiry))]);
let parsed = dpp_crypto::sd_jwt::SdJwt::parse(&credential).unwrap();
assert!(
parsed.digests_for_claim("exp").is_empty(),
"exp travels as a disclosure, so a holder can withhold its own expiry"
);
let bare = parsed.present(&[]).serialise();
assert!(matches!(
check(&store, &bare, Utc.timestamp_opt(expiry + 1, 0).unwrap()),
Err(SdJwtVcError::Expired { .. })
));
}
#[test]
fn a_fractional_numericdate_is_accepted_and_keeps_its_fraction() {
let store = temp_store("sdjwtvc-fractional", KEY_ID);
let expiry = ISSUED_AT + 3600;
let credential = issued_with(&store, &[("exp", json!(expiry as f64 + 0.5))]);
check(&store, &credential, Utc.timestamp_opt(expiry, 0).unwrap())
.expect("a credential is valid up to its fractional expiry");
assert!(matches!(
check(
&store,
&credential,
Utc.timestamp_opt(expiry + 1, 0).unwrap()
),
Err(SdJwtVcError::Expired { .. })
));
}
#[test]
fn a_non_finite_or_out_of_range_numericdate_is_still_refused() {
let store = temp_store("sdjwtvc-notfinite", KEY_ID);
for value in [json!(1.0e300), json!(-1.0e300)] {
let credential = issued_with(&store, &[("exp", value.clone())]);
assert!(
matches!(
check(&store, &credential, now()),
Err(SdJwtVcError::MalformedTemporalClaim(_))
),
"{value} was accepted as an expiry"
);
}
}
fn foreign_credential_with_iss(
store: &dpp_crypto::keystore::KeyStore,
iss: serde_json::Value,
) -> String {
let jwt_payload = json!({
"iss": iss,
"vct": vct_for(ProductGroup::Battery, SCHEMA_VERSION),
"iat": ISSUED_AT,
});
let jwt = dpp_crypto::jws::sign_typed(store, KEY_ID, &jwt_payload, Some(super::TYP))
.expect("fixture key signs");
dpp_crypto::sd_jwt::SdJwt::new(jwt, Vec::new()).serialise()
}
#[test]
fn an_iss_that_is_not_a_string_is_refused() {
let store = temp_store("sdjwtvc-iss-shape", KEY_ID);
for value in [json!(null), json!(42), json!(["a"]), json!({})] {
let credential = foreign_credential_with_iss(&store, value.clone());
assert!(
matches!(
check(&store, &credential, now()),
Err(SdJwtVcError::MissingClaim("iss"))
),
"iss as {value} was accepted"
);
}
}
#[test]
fn a_nested_field_sharing_a_registered_claim_name_is_still_concealed() {
use std::collections::HashMap;
use dpp_domain::Disclosure;
use dpp_domain::access::ProductGroupAccessPolicy;
let store = temp_store("sdjwtvc-nested-exp", KEY_ID);
let mut field_disclosure = HashMap::new();
field_disclosure.insert("exp".to_owned(), Disclosure::Individual);
let policy = ProductGroupAccessPolicy {
name: "nested-registered-name".into(),
product_group: "battery".into(),
field_disclosure,
envelope_disclosure: HashMap::new(),
default_disclosure: Disclosure::Public,
public_envelope_fields: Default::default(),
};
let payload = json!({
"id": "018f3a4c-0000-7000-8000-000000000001",
"productGroup": "battery",
"schemaVersion": SCHEMA_VERSION,
"productGroupData": { "exp": "a non-public value that is not an expiry" },
});
let credential = super::issue(
&store,
KEY_ID,
&payload,
&policy,
ISSUER,
&vct_for(ProductGroup::Battery, SCHEMA_VERSION),
ISSUED_AT,
)
.expect("issue")
.serialise();
let parsed = dpp_crypto::sd_jwt::SdJwt::parse(&credential).unwrap();
assert!(
!parsed.digests_for_claim("exp").is_empty(),
"a nested, non-public `exp` travelled in cleartext — the root-claim \
exemption is leaking into the payload"
);
assert!(
parsed.digests_for_claim("iat").is_empty(),
"the root `iat` was concealed — the exemption is now too narrow"
);
}