From fb855bf150d6d8afb71d176d828ad809d9dfea9f Mon Sep 17 00:00:00 2001 From: makeworld Date: Thu, 28 May 2026 16:50:16 -0400 Subject: [PATCH] Add check_credential_status for VC 2.0 status lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New public async fn pairs with verify_vc to cover the second half of "is this credential currently usable?" — fetches each entry's BitstringStatusListCredential, verifies its proof, decodes the bitstring, and reads the bit. Both purposes (revocation, suspension) are reported in a single CredentialStatus { revoked, suspended } struct; multiple entries of the same purpose are OR'd. Deliberately separate from verify_vc: the input credential's own proof is NOT touched, so callers compose the two checks rather than getting them implicitly bundled. Implementation pulls credentialStatus off the JSON via serde to avoid going through AnyEntrySet::from_bytes_with, which would have re-verified the input as a side effect. Requires a status_list_signer DID — every fetched status list must be signed by a key whose controller DID matches, preventing a MITM from substituting their own valid DID-signed bitstring. Compared on the prefix before '#' so key-id rotation within the same DID document is accepted. A test-only check_credential_status_inner exposes (Option, allow_unsecured) so wiremock tests can serve unsigned fixtures without the production helper having a footgun. Tests cover: no credentialStatus → both None; clear bits → (Some(false), Some(false)); revoked bit at index 42 → (Some(true), Some(false)); suspended bit → symmetric; public entrypoint rejects unsigned status lists with an "unsigned" error. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 1 + integrity-vc/Cargo.toml | 3 + integrity-vc/src/lib.rs | 461 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 462 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3f58f55..0c5689f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4277,6 +4277,7 @@ dependencies = [ "serde_json", "ssi 0.16.0", "ssi 0.7.0", + "ssi-status", "tokio", "uuid", "wiremock", diff --git a/integrity-vc/Cargo.toml b/integrity-vc/Cargo.toml index 86455e2..91d036d 100644 --- a/integrity-vc/Cargo.toml +++ b/integrity-vc/Cargo.toml @@ -35,6 +35,9 @@ iref = "3.2" json-syntax = "0.12" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } serde = { version = "1.0", features = ["derive"] } +# Status-list checking (BitstringStatusList revocation/suspension). Pulled in +# transitively by `ssi` already; pinned directly so we can use its public API. +ssi-status = "0.8" # ssi 0.7 (aliased) is kept exclusively for verifying VCs issued by # pre-ssi-0.16 versions of this repo: legacy VCs use a v2 `@context` with # v1-era `issuanceDate` plus undefined custom evidence terms, which ssi diff --git a/integrity-vc/src/lib.rs b/integrity-vc/src/lib.rs index 1ca65dc..d434ce4 100644 --- a/integrity-vc/src/lib.rs +++ b/integrity-vc/src/lib.rs @@ -186,9 +186,20 @@ pub async fn sign_vc(unsigned: JsonCredential, signer: SignerType) -> Result Result { if is_legacy_vc(vc_json) { @@ -207,6 +218,219 @@ pub async fn verify_vc(vc_json: &str) -> Result { Ok("VC verification result: ok".to_string()) } +/// Outcome of a credential-status check. +/// +/// Each field is `None` when the credential carries no `credentialStatus` +/// entry for that purpose, `Some(false)` when the status bit is clear, and +/// `Some(true)` when set. If a credential has multiple entries for the +/// same purpose (uncommon but spec-legal), their bits are OR'd — any one +/// set marks the credential as revoked/suspended. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CredentialStatus { + pub revoked: Option, + pub suspended: Option, +} + +/// Reads the bits referenced by the credential's `credentialStatus` +/// entries and reports whether revocation / suspension are set. +/// +/// **Does not verify the input credential's own Data-Integrity proof.** +/// By design — proof verification belongs to [`verify_vc`]. Status truth +/// and signature truth are separate concerns; bundling them into one +/// call would couple two independent failure modes ("revoked" vs +/// "forged") and make the proof check implicit. Pair both functions +/// when you need both answers. +/// +/// For each `credentialStatus` entry, fetches the referenced +/// `BitstringStatusListCredential`, **verifies that credential's own +/// Data-Integrity proof**, confirms it was signed by `status_list_signer`, +/// decodes the multibase/gzipped bitstring, and reads the bit at +/// `statusListIndex`. The two purposes (`"revocation"`, `"suspension"`) +/// are reported independently in the returned [`CredentialStatus`]; +/// multiple entries for the same purpose are OR'd (any set ⇒ revoked). +/// +/// # Required: `status_list_signer` +/// +/// The DID expected to have signed every fetched status-list credential +/// — typically the issuer's DID, or the status server's DID if the +/// issuer delegates status-list signing. We refuse to read a status +/// from a list that is unsigned, signed by a DID other than +/// `status_list_signer`, or whose proof doesn't verify. Without this +/// pin, an attacker who can intercept the GET (DNS hijack, compromised +/// CDN, MITM on a non-TLS hop) could substitute their own valid +/// DID-signed bitstring and silently unflip the revocation bit. The +/// comparison is on the controller DID — everything before `#` in the +/// proof's `verificationMethod` IRI — so key rotation within the same +/// DID document is accepted; a different controller is not. +/// +/// # No-status / legacy / unsupported cases +/// +/// - No `credentialStatus` field, `null`, or empty array → both fields +/// `None`. (`status_list_signer` is ignored in this case.) +/// - Legacy (pre-ssi-0.16) VCs → both fields `None`: revocable VCs are +/// a post-VC-2.0 feature and legacy VCs by construction don't carry +/// `credentialStatus`. +/// - `"message"`-purpose entries are skipped (out of scope here). +#[cfg(not(target_arch = "wasm32"))] +pub async fn check_credential_status( + vc_json: &str, + status_list_signer: &str, +) -> Result { + check_credential_status_inner(vc_json, Some(status_list_signer), false).await +} + +/// Test-only entrypoint behind [`check_credential_status`]. +/// +/// - `status_list_signer = Some(did)` enforces the signer pin documented +/// on [`check_credential_status`]. +/// - `status_list_signer = None` skips the pin entirely. **Never** pass +/// `None` outside tests: doing so accepts any valid DID-signed list, +/// defeating the security invariant the public entrypoint exists to +/// enforce. +/// - `allow_unsecured = true` accepts status-list credentials that +/// carry no proof at all (used so wiremock tests don't have to sign +/// their fixtures). Implies trusting the transport; do not set in +/// production. +#[cfg(not(target_arch = "wasm32"))] +async fn check_credential_status_inner( + vc_json: &str, + status_list_signer: Option<&str>, + allow_unsecured: bool, +) -> Result { + use ssi::claims::data_integrity::{self, AnySuite, DataIntegrity}; + use ssi_status::{ + bitstring_status_list::{ + BitstringStatusListCredential, BitstringStatusListEntry, StatusPurpose as BsPurpose, + }, + StatusMap, + }; + + if is_legacy_vc(vc_json) { + return Ok(CredentialStatus { + revoked: None, + suspended: None, + }); + } + + // Pull `credentialStatus` straight off the input JSON via serde. We + // deliberately do NOT route this through `ssi_status::AnyEntrySet`, + // because that path parses the whole credential as a + // `BitstringStatusListEntrySetCredential` and re-runs Data-Integrity + // proof verification on the input as a side effect — which would + // silently couple this function to `verify_vc`'s job. Status + // checking and proof verification are intentionally separate. + let v: Value = + serde_json::from_str(vc_json).map_err(|e| anyhow!("input is not valid JSON: {e}"))?; + let entries: Vec = match v.get("credentialStatus") { + None | Some(Value::Null) => Vec::new(), + Some(Value::Array(arr)) => arr + .iter() + .map(|e| serde_json::from_value(e.clone())) + .collect::>() + .map_err(|e| anyhow!("malformed credentialStatus entry: {e}"))?, + Some(single) => vec![serde_json::from_value(single.clone()) + .map_err(|e| anyhow!("malformed credentialStatus entry: {e}"))?], + }; + if entries.is_empty() { + return Ok(CredentialStatus { + revoked: None, + suspended: None, + }); + } + + let resolver = VerificationMethodDIDResolver::<_, AnyMethod>::new(AnyDidMethod::default()); + let loader = integrity_jsonld::loader::loader(None)?; + let verifier = VerificationParameters::from_resolver(resolver).with_json_ld_loader(loader); + let http = reqwest::Client::new(); + + let mut revoked: Option = None; + let mut suspended: Option = None; + + for entry in &entries { + let slot = match entry.status_purpose { + BsPurpose::Revocation => &mut revoked, + BsPurpose::Suspension => &mut suspended, + BsPurpose::Message => continue, + }; + + let url = &entry.status_list_credential; + let bytes = http + .get(url.as_str()) + .send() + .await + .map_err(|e| anyhow!("failed to GET status list at {url}: {e}"))? + .bytes() + .await + .map_err(|e| anyhow!("failed to read status list body from {url}: {e}"))?; + + let vc: DataIntegrity = + data_integrity::from_json_slice(&bytes) + .map_err(|e| anyhow!("malformed status list at {url}: {e}"))?; + + if vc.proofs.is_empty() { + if !allow_unsecured { + bail!("status list at {url} is unsigned; refusing to trust it"); + } + } else { + // Cryptographic verification first ... + vc.verify(&verifier) + .await + .map_err(|e| anyhow!("verification error for status list at {url}: {e}"))? + .map_err(|e| anyhow!("invalid status list proof at {url}: {e:?}"))?; + + // ... then signer pinning: at least one proof must be from + // the DID the caller approved. Compare on the controller + // portion of the verificationMethod IRI (everything before + // `#`) so key-id rotations within the same DID document + // still pass. + if let Some(expected) = status_list_signer { + let signed_by_expected = vc.proofs.iter().any(|p| { + let vm = p.verification_method.id().as_str(); + let controller = vm.split_once('#').map_or(vm, |(c, _)| c); + controller == expected + }); + if !signed_by_expected { + let actual: Vec<&str> = vc + .proofs + .iter() + .map(|p| p.verification_method.id().as_str()) + .collect(); + bail!( + "status list at {url} not signed by expected DID `{expected}`; \ + verificationMethod(s): {actual:?}" + ); + } + } + } + + let status_list = vc + .claims + .decode_status_list() + .map_err(|e| anyhow!("failed to decode status list bitstring at {url}: {e}"))?; + + let bit = status_list + .get_entry(entry) + .map_err(|e| { + anyhow!( + "invalid status size for entry at index {}: {e}", + entry.status_list_index + ) + })? + .ok_or_else(|| { + anyhow!( + "status list index {} out of range for {url}", + entry.status_list_index + ) + })?; + + let current = bit != 0; + *slot = Some(slot.unwrap_or(false) || current); + } + + Ok(CredentialStatus { revoked, suspended }) +} + /// Detect a legacy VC: a JSON document whose top-level `@context` references /// the W3C VC 2.0 base context AND whose top-level fields include /// `issuanceDate` (a v1-only field that v2 doesn't define). Together these @@ -830,4 +1054,235 @@ mod tests { "non-object subject must be rejected, got: {result:?}" ); } + + /// A VC with no `credentialStatus` field — the plain `issue_vc` path — + /// reports no statement about either purpose. The signer pin is + /// irrelevant here because no list is ever fetched; we pass a + /// placeholder DID to satisfy the required parameter. + #[tokio::test] + async fn test_check_credential_status_no_status_list() { + let _ = env_logger::builder().is_test(true).try_init(); + + let signer = Ed25519Signer::create().unwrap(); + let signed = issue_vc( + "urn:cid:bafkr4ibthuzk3zug7ghmx63yjqaiu6rx4hhfdv3453j5bodskgw57bx2ya", + SignerType::ED25519(signer), + ) + .await + .unwrap(); + let vc_json = serde_json::to_string(&signed).unwrap(); + + let status = check_credential_status(&vc_json, "did:key:irrelevant-no-fetch-occurs") + .await + .unwrap(); + assert_eq!( + status, + CredentialStatus { + revoked: None, + suspended: None + } + ); + } + + /// End-to-end: issue a revocable VC against a mocked status server, + /// serve unsigned all-zero status lists, and verify both bits read as + /// clear. Goes through the test-only inner helper so the in-test + /// status lists don't need a Data-Integrity proof or signer pin. + #[tokio::test] + async fn test_check_credential_status_clear_bits() { + let status = run_revocable_status_check(None).await.unwrap(); + assert_eq!( + status, + CredentialStatus { + revoked: Some(false), + suspended: Some(false) + } + ); + } + + /// Same setup as the clear-bits test, but with the revocation list's + /// bit at the credential's `statusListIndex` flipped to 1. + #[tokio::test] + async fn test_check_credential_status_revoked() { + let status = run_revocable_status_check(Some(StatusBitToSet::Revocation)) + .await + .unwrap(); + assert_eq!( + status, + CredentialStatus { + revoked: Some(true), + suspended: Some(false) + } + ); + } + + /// And the symmetric case for suspension. + #[tokio::test] + async fn test_check_credential_status_suspended() { + let status = run_revocable_status_check(Some(StatusBitToSet::Suspension)) + .await + .unwrap(); + assert_eq!( + status, + CredentialStatus { + revoked: Some(false), + suspended: Some(true) + } + ); + } + + /// The public entrypoint must REJECT a status list with no proof — + /// otherwise an attacker who can intercept the GET could serve a + /// fresh, unsigned bitstring with the revocation bit cleared. Drive + /// the same wiremock topology, then call the public function (which + /// requires `allow_unsecured = false`) and expect an error. + #[tokio::test] + async fn test_check_credential_status_rejects_unsigned_status_list() { + let err = run_revocable_status_check_public(None, "did:key:irrelevant-will-fail-first") + .await + .unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("unsigned"), + "error should explain the rejection: {msg}" + ); + } + + #[derive(Clone, Copy)] + enum StatusBitToSet { + Revocation, + Suspension, + } + + /// Drives the revocable-status flow against the test-only inner + /// helper (no signer pin, accepts unsigned lists). + async fn run_revocable_status_check(set: Option) -> Result { + let (_server, vc_json) = setup_revocable_vc_against_mock(set).await; + check_credential_status_inner(&vc_json, None, true).await + } + + /// Same setup, but call the PUBLIC entrypoint — i.e. the one that + /// requires a signed status list and a pinned signer. Used to assert + /// rejection of unsigned lists. + async fn run_revocable_status_check_public( + set: Option, + signer_did: &str, + ) -> Result { + let (_server, vc_json) = setup_revocable_vc_against_mock(set).await; + check_credential_status(&vc_json, signer_did).await + } + + /// Stands up the wiremock topology used by every status-check test: + /// (1) mock POST /credentials/status/allocate → returns the VC with + /// `credentialStatus` pointing back at the mock for two lists; (2) + /// mock GET /status-lists/{revocation,suspension} → returns unsigned + /// `BitstringStatusListCredential` JSON whose encoded list is either + /// all zeros or has the entry's bit set. Returns the running server + /// (kept alive by the caller's binding) and the serialized signed VC. + async fn setup_revocable_vc_against_mock( + set: Option, + ) -> (wiremock::MockServer, String) { + use ssi_status::bitstring_status_list::{SizedBitString, StatusSize}; + use wiremock::{ + matchers::{header, method, path}, + Mock, MockServer, Request, Respond, ResponseTemplate, + }; + + const REVOCATION_INDEX: usize = 42; + const SUSPENSION_INDEX: usize = 43; + + struct AllocateResponder { + server_uri: String, + } + impl Respond for AllocateResponder { + fn respond(&self, req: &Request) -> ResponseTemplate { + let mut vc: serde_json::Value = serde_json::from_slice(&req.body).unwrap(); + vc["credentialStatus"] = serde_json::json!([ + { + "id": format!("{}/status-lists/revocation#{REVOCATION_INDEX}", self.server_uri), + "type": "BitstringStatusListEntry", + "statusPurpose": "revocation", + "statusListIndex": REVOCATION_INDEX.to_string(), + "statusListCredential": format!("{}/status-lists/revocation", self.server_uri), + }, + { + "id": format!("{}/status-lists/suspension#{SUSPENSION_INDEX}", self.server_uri), + "type": "BitstringStatusListEntry", + "statusPurpose": "suspension", + "statusListIndex": SUSPENSION_INDEX.to_string(), + "statusListCredential": format!("{}/status-lists/suspension", self.server_uri), + } + ]); + ResponseTemplate::new(200).set_body_json(vc) + } + } + + fn status_list_body(server_uri: &str, purpose: &str, set_index: Option) -> String { + let status_size = StatusSize::try_from(1u8).unwrap(); + let mut bs = SizedBitString::new_zeroed(status_size, 16_384); + if let Some(i) = set_index { + bs.set(i, 1).unwrap(); + } + let encoded = bs.encode(); + serde_json::json!({ + "@context": ["https://www.w3.org/ns/credentials/v2"], + "id": format!("{server_uri}/status-lists/{purpose}"), + "type": ["VerifiableCredential", "BitstringStatusListCredential"], + "credentialSubject": { + "type": "BitstringStatusList", + "statusPurpose": purpose, + "encodedList": encoded, + } + }) + .to_string() + } + + let _ = env_logger::builder().is_test(true).try_init(); + let server = MockServer::start().await; + let server_uri = server.uri(); + + Mock::given(method("POST")) + .and(path("/credentials/status/allocate")) + .and(header("Authorization", "Bearer test-jwt")) + .respond_with(AllocateResponder { + server_uri: server_uri.clone(), + }) + .mount(&server) + .await; + + let revocation_set = + matches!(set, Some(StatusBitToSet::Revocation)).then_some(REVOCATION_INDEX); + let suspension_set = + matches!(set, Some(StatusBitToSet::Suspension)).then_some(SUSPENSION_INDEX); + + Mock::given(method("GET")) + .and(path("/status-lists/revocation")) + .respond_with(ResponseTemplate::new(200).set_body_raw( + status_list_body(&server_uri, "revocation", revocation_set), + "application/vc+ld+json", + )) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/status-lists/suspension")) + .respond_with(ResponseTemplate::new(200).set_body_raw( + status_list_body(&server_uri, "suspension", suspension_set), + "application/vc+ld+json", + )) + .mount(&server) + .await; + + let signer = Ed25519Signer::create().unwrap(); + let signed = issue_revocable_vc( + "did:key:z6Mkw2PvzC9DHXiYQHMDRwyxCCV9n4EDc6vqqp1uyi9nrwsP", + SignerType::ED25519(signer), + &server_uri, + "test-jwt", + ) + .await + .unwrap(); + let vc_json = serde_json::to_string(&signed).unwrap(); + (server, vc_json) + } }