Background
Two public surfaces serve a passport, and they are not built the same way:
- Resolver (
/dpp/{id}, /01/{gtin}): verifies publicJwsSignature, then renders from the decoded signed payload (dpp-resolver/src/infra/did.rs:102-104, resolve_json.rs:60). Body and proof can never disagree.
- Vault (
/public/dpp/{id}, public_read_handler in crates/dpp-vault/src/handlers/public_read.rs): looks up the live row (find_by_id_any_status), serializes it, and redacts it to the Public tier via public_view(&full, ...). The redacted body still carries publicJwsSignature — a frozen proof attached to a live, potentially-since-changed body.
Impact
Public-tier fields that are mutable after publish (lintResult — odal-node/dpp-core#40 — plus status, qrCodeUrl, publishedAt, updatedAt) can diverge from what publicJwsSignature actually signed at publish time. Anyone who fetches from the vault's public route (as opposed to the resolver) and verifies the embedded signature against the current body will get a mismatch that isn't tampering — it's just two different code paths serving the "same" public view differently.
The resolver is the canonical/intended verification path, so this is not a spoofing hole. It is a correctness/consistency defect on a secondary public route that undermines the "every served field is bound to a signed proof" promise for anyone who verifies off the vault endpoint directly.
Recommended fix
Preferred: have public_read_handler render from the signed publicJwsSignature payload the same way the resolver does, so both public surfaces are a single code path with byte-identical output. Alternative if it must serve the live row: strip publicJwsSignature from that response so nothing invites a consumer to verify a body that was never signed in this shape.
Found during a 2026-07-18 full-codebase review; verified directly against current main.
Background
Two public surfaces serve a passport, and they are not built the same way:
/dpp/{id},/01/{gtin}): verifiespublicJwsSignature, then renders from the decoded signed payload (dpp-resolver/src/infra/did.rs:102-104,resolve_json.rs:60). Body and proof can never disagree./public/dpp/{id},public_read_handlerincrates/dpp-vault/src/handlers/public_read.rs): looks up the live row (find_by_id_any_status), serializes it, and redacts it to the Public tier viapublic_view(&full, ...). The redacted body still carriespublicJwsSignature— a frozen proof attached to a live, potentially-since-changed body.Impact
Public-tier fields that are mutable after publish (
lintResult— odal-node/dpp-core#40 — plusstatus,qrCodeUrl,publishedAt,updatedAt) can diverge from whatpublicJwsSignatureactually signed at publish time. Anyone who fetches from the vault's public route (as opposed to the resolver) and verifies the embedded signature against the current body will get a mismatch that isn't tampering — it's just two different code paths serving the "same" public view differently.The resolver is the canonical/intended verification path, so this is not a spoofing hole. It is a correctness/consistency defect on a secondary public route that undermines the "every served field is bound to a signed proof" promise for anyone who verifies off the vault endpoint directly.
Recommended fix
Preferred: have
public_read_handlerrender from the signedpublicJwsSignaturepayload the same way the resolver does, so both public surfaces are a single code path with byte-identical output. Alternative if it must serve the live row: strippublicJwsSignaturefrom that response so nothing invites a consumer to verify a body that was never signed in this shape.Found during a 2026-07-18 full-codebase review; verified directly against current
main.