
Cloud Vulnerability DB
A community-led vulnerabilities database
In @openeudi/openid4vp 0.18.0, verifyPresentation returns valid: true for ten presentations that a public set of negative test vectors expects a verifier to refuse. Seven of the ten vectors quote the sentence of the specification they rest on; three cite ISO/IEC 18013-5 without a quotation (see the note below item 9). Five are SD-JWT VC presentations and five are mdoc DeviceResponses.
The same call accepts all five controls of the set and all 32 repaired twins of the mdoc vectors, and it refuses the other 44 of the 54 must-refuse vectors. The inputs and the call are therefore sound, and the ten acceptances are specific to these vectors.
Two of the ten change what the caller is told (items 1 and 2). For the other eight the measured effect is the verdict alone.
v0.18.0, commit 21839bcc0acd674d8c70e4e4ea8ee918d1b4bbf6. main is identical to the tag today (0 commits apart), and npm's latest is 0.18.0.dist/index.js is byte-equal to the published one (sha256 55a63b0f52faa97ea1f5e65fed153a8993103285f3dd05092dbb531e738abf87).verifyPresentation, with each file's own trust anchors or issuer JWKs, nonce and audience, the SessionTranscript from the library's two OpenID4VP builders, and the clock set to the instant each file is signed for.vectors.json 0.4.0 (37 vectors) and mdoc-vectors.json 0.1.0 (34 vectors, 32 of them with a repaired twin), both at commit 2fd4edcf0902fb708409d8fc9b54ed7136b7567b of the public repository EgorKhaklin/polaris-id, directory lab/interop/negative-vectors/.clockSkewTolerance: 0 and allowedAlgorithms: ['ES256'], plus requireKeyBinding: true for SD-JWT VC and expectedDocType for mdoc, all ten are still accepted..example hosts only.
Each vector carries a class. spec: a sentence of the cited text requires the refusal. spec-implied: the cited sentence binds the issuer or the wallet, or a definition implies the refusal; the mdoc file adds "or the clause is ISO-only, its text not read", which is the case for items 7, 8 and 9. The class is given with each item, because for a spec-implied vector the verifier's duty to refuse is a reading and yours to judge.In eight of the ten the malformed part is inside what the issuer signs (the SD-JWT payload, its _sd digests, the JOSE header, or the IssuerAuth structure). Such a credential comes from an issuer that does not follow the specification. A holder cannot make one out of a well-formed credential.
Items 8 and 9 are different: the malformed part is in the device-signed half, which the holder's wallet produces. In both, the library ignores the extra data and does not return it.
Line numbers are at commit 21839bcc. "Returns" is the measured result at 0.18.0.
disclosed-exp: an expiry carried in a Disclosure is never checkeddisclosed-exp (class spec-implied). Clause, SD-JWT VC draft-19 s2.2.2.3: "The following registered JWT claims, including any of their sub-claims, when used in the SD-JWT component of an SD-JWT VC, MUST NOT be included in Disclosures and therefore MUST NOT be selectively disclosed"exp travels in a Disclosure instead of the payload.valid: true and the same three claims as the control accept-x5c.exp value (1822348800), the control, which carries that value in the signed payload, is refused ("Issuer JWT signature verification failed"). This vector, which carries the same value in a Disclosure, is accepted once kbJwtMaxAge is widened. With the default kbJwtMaxAge it is refused at that clock for the age of the fixture's KB-JWT alone. The widened option stands in for a KB-JWT signed at presentation time; a freshly signed one was not tested.src/parsers/sd-jwt.parser.ts:280. jwtVerify on the issuer JWT is the only expiry check, and it reads exp from the signed payload. No line refuses a Disclosure named exp, and :139-146 leave exp out of the returned claims, so the caller does not see the value either.claim-overwrite: a Disclosure replaces a claim the signed payload holds in the clearclaim-overwrite (class spec). Clause, RFC 9901 s7.1 step 3.c.ii.3: "If the claim name already exists at the level of the _sd key, the SD-JWT MUST be rejected."valid: true with given_name: "Erika", the Disclosure's value. The signed payload holds given_name: "Max" in the clear.src/parsers/sd-jwt.parser.ts:412-431 decodes each Disclosure and keeps its hash, length and value; the claim name is not read. :573-574 then hands payload and Disclosures to getClaims of @sd-jwt/decode 0.19.0, where the disclosed value is written over the clear one (dist/index.mjs:176 and :181).disclosed-iss: an issuer identifier carried in a Disclosure is accepteddisclosed-iss (class spec-implied). Clause: SD-JWT VC draft-19 s2.2.2.3, the sentence quoted under item 1.iss travels in a Disclosure instead of the payload.valid: true, with the same claims and the same issuer object as the control accept-jwks.src/parsers/sd-jwt.parser.ts:328 reads iss from the signed payload only, as an empty string when it is absent, and no line refuses a Disclosure named iss.disclosure-named-sd: a Disclosure whose claim name is _sd is accepted (a dependency's published advisory)disclosure-named-sd (class spec). Clause, RFC 9901 s7.1 step 3.c.ii.2: "If the claim name is _sd or ..., the SD-JWT MUST be rejected."_sd.valid: true and the same claims as the control accept-x5c. The _sd entry itself is not returned (:139 drops keys that start with _sd).src/parsers/sd-jwt.parser.ts:412-431, and getClaims of @sd-jwt/decode 0.19.0 accepts it.@sd-jwt/decode 0.19.0 and earlier, lists "Reserved claim name _sd accepted" among its related findings. The advisory gives no patched release of @sd-jwt/decode, which npm marks as deprecated ("Merged into @sd-jwt/core (>= 0.20.0)"), and gives @sd-jwt/core 0.20.0 as patched. Whether 0.20.0 refuses this vector was not tested. What is reported here is this verifier's measured result at 0.18.0, whose package.json on main names @sd-jwt/decode ^0.19.0.sd-jwt.parser.ts checks every Disclosure against the signed digests itself, and the run refuses those vectors ("Disclosure hash mismatch").md-issuerauth-crit-unknown: an unknown critical header parameter in IssuerAuth is ignoredmd-issuerauth-crit-unknown (class spec-implied). Clause, RFC 9052 3.1: "This header parameter is used to indicate which protected header parameters an application that is processing a message is required to understand."crit a private-use label (-65537) that it carries and no verifier understands.valid: true and the same claims as the vector's repaired twin.src/crypto/cose-sign1.ts knows two header labels (:18 alg, :23 x5chain), and :73 reads alg alone. crit is read nowhere in cose-sign1.ts, device-auth.ts or mdoc.parser.ts.md-issuerauth-dup-label: a protected header with a repeated label is acceptedmd-issuerauth-dup-label (class spec). Clause, RFC 9052 3: "Labels in each of the maps MUST be unique. When processing messages, if a label appears multiple times, the message MUST be rejected as malformed."a2 01 26 01 26), signed as those bytes.valid: true and the same claims as the repaired twin.src/crypto/cose-sign1.ts:64 decodes the protected header into a Map, where a repeated label collapses into one entry. No line checks that the labels are unique. The result is the same with cbor-x 1.6.4 (the lockfile) and 1.6.9 (a fresh install).md-mso-not-tag24: an IssuerAuth payload that is a bare MSO is acceptedmd-mso-not-tag24 (class spec-implied). Clause as the vector cites it: ISO/IEC 18013-5, MobileSecurityObjectBytes, with no quotation and the clause number inferred (see the note below item 9).valid: true and the same claims as the repaired twin.src/crypto/mso.ts:58-63 decodes the payload, unwraps it when it is tag 24, and uses it as it is when it is already a map. Nothing refuses the bare form before :84.md-devicesigned-unauthorized-ns: a device-signed element outside the MSO's KeyAuthorizations is acceptedmd-devicesigned-unauthorized-ns (class spec-implied). Clause as the vector cites it: ISO/IEC 18013-5 9.1.3.4 and 12.3.4, with no quotation and the numbers inferred.valid: true. namespacedClaims holds the issuer-signed name space alone (three elements): the device-signed element is not handed to the caller (src/parsers/mdoc.parser.ts:453 maps issuer-signed items only).src/crypto/device-auth.ts:158-160 feeds the device-signed name spaces into the DeviceAuthentication bytes and nowhere else. No line compares them with the MSO's KeyAuthorizations (keyAuthorizations has no match in src/, in any letter case).md-devicesig-not-detached: a DeviceSignature that carries its payload is acceptedmd-devicesig-not-detached (class spec-implied). Clause as the vector cites it: ISO/IEC 18013-5, with no quotation and the clause number inferred.valid: true and the same claims as the repaired twin.src/crypto/device-auth.ts:138-139 reads elements 0 and 3 of the DeviceSignature. Element 2, the payload, is never read, and the signature is verified over the DeviceAuthentication bytes the verifier builds itself (:160). The carried payload is ignored, not trusted.
A note on items 7, 8 and 9: their vectors cite ISO/IEC 18013-5 without quoting it. The ISO text was not read for these vectors, and the clause numbers are inferred. If the text reads otherwise, these three are not findings.x5c-includes-anchor: an x5c that carries the trust anchor's own certificate is accepted (a conformance note)x5c-includes-anchor (class spec-implied). Clause, HAIP 1.0 s6.1.1: "The X.509 certificate of the trust anchor MUST NOT be included in the x5c JOSE header of the SD-JWT VC."x5c carries the trust anchor's own certificate.valid: true and the same claims as the control accept-x5c.src/parsers/sd-jwt.parser.ts:342-351 passes the x5c entries after the first as untrusted path candidates, and nothing refuses one that is the anchor's own certificate.About a minute, with no network after the install and the two downloads.
mkdir repro && cd repro
npm init -y
npm install --ignore-scripts @openeudi/openid4vp@0.18.0
B=https://raw.githubusercontent.com/EgorKhaklin/polaris-id/2fd4edcf0902fb708409d8fc9b54ed7136b7567b/lab/interop/negative-vectors
curl -sSfLO $B/vectors.json
curl -sSfLO $B/mdoc-vectors.json
shasum -a 256 vectors.json mdoc-vectors.json
# 49d92709c514ea26443d08843ba64f3ec37b69c07902e7040f7cfc7d2958f874 vectors.json
# 2525e629f92adad0a4a44649f7868eb9ecab84bc60135a126862f118a28857de mdoc-vectors.json
node repro.mjsrepro.mjs:
// repro.mjs: every vector of vectors.json and mdoc-vectors.json through verifyPresentation.
// Prints each vector whose verdict differs from the file's expected one, then one clock check.
// Run: node repro.mjs (in a directory holding the two files and @openeudi/openid4vp; no network)
import { readFileSync } from 'node:fs';
import * as oe from '@openeudi/openid4vp';
const RealDate = Date;
function setNow(ms) {
// the vectors are signed for a fixed instant, so the clock is set to it
globalThis.Date = class extends RealDate {
constructor(...a) {
if (a.length) super(...a);
else super(ms);
}
static now() {
return ms;
}
};
}
async function verdict(call) {
try {
return await call();
} catch (e) {
return { valid: false, parsed: { error: String(e?.message) } };
}
}
const rows = [];
const sd = JSON.parse(readFileSync('vectors.json', 'utf8'));
const s = sd.settings;
const sdQuery = { credentials: [{ id: 'c1', format: 'dc+sd-jwt', meta: { vct_values: [s.vct] } }] };
const sdTrust = (v) =>
v.trust === 'jwks' ? { trustedIssuerJwks: s.issuer_jwks } : { trustStore: new oe.StaticTrustStore(s.trust_anchors_pem) };
for (const v of sd.vectors) {
setNow(s.now * 1000);
rows.push([v, await verdict(() => oe.verifyPresentation(v.presentation, sdQuery, { nonce: s.nonce, audience: s.audience, ...sdTrust(v) }))]);
}
const md = JSON.parse(readFileSync('mdoc-vectors.json', 'utf8'));
for (const v of md.vectors) {
setNow(RealDate.parse(v.at));
const i = v.inputs;
const cred = { id: 'm1', format: 'mso_mdoc', meta: { doctype_value: i.doctype_value } };
if (i.claims) cred.claims = i.claims;
if (i.claim_sets) cred.claim_sets = i.claim_sets;
rows.push([
v,
await verdict(async () =>
oe.verifyPresentation(new Uint8Array(Buffer.from(v.response, 'base64url')), { credentials: [cred] }, {
nonce: i.nonce,
mdocSessionTranscript: String(i.response_mode).startsWith('dc_api')
? await oe.buildOpenID4VPDCAPIHandoverSessionTranscript({ origin: i.origin, nonce: i.nonce, verifierEncryptionJwk: i.encryption_jwk })
: await oe.buildOpenID4VPHandoverSessionTranscript({ clientId: i.client_id, nonce: i.nonce, responseUri: i.response_uri, verifierEncryptionJwk: i.encryption_jwk }),
trustStore: new oe.StaticTrustStore(v.trust_anchors_pem),
})
),
]);
}
console.log(`@openeudi/openid4vp ${oe.VERSION}, node ${process.version}`);
let controls = 0, controlsOk = 0, must = 0, refused = 0;
for (const [v, r] of rows) {
const got = r.valid === true ? 'accept' : 'reject';
const isControl = v.class === 'control';
// must-refuse: a cited sentence requires or implies the refusal; the status-list layer needs a status policy and is left out
const isMust = (v.class === 'spec' || v.class === 'spec-implied') && v.layer !== 'status-list';
if (isControl) { controls += 1; controlsOk += got === 'accept' ? 1 : 0; }
if (isMust) { must += 1; refused += got === 'reject' ? 1 : 0; }
if ((isControl || isMust) && got !== v.expected) {
const extra = v.id === 'claim-overwrite' ? `; returned given_name ${JSON.stringify(r.parsed?.claims?.given_name)}` : '';
console.log(`${v.id}: expected ${v.expected}, got ${got}${extra}`);
}
}
console.log(`controls accepted ${controlsOk} of ${controls}; must-refuse vectors refused ${refused} of ${must}`);
// The clock one day after the exp value. accept-x5c carries exp in the signed payload, disclosed-exp in a Disclosure.
// kbJwtMaxAge is widened so that the age of the fixture's KB-JWT is not what decides.
const control = sd.vectors.find((v) => v.id === 'accept-x5c');
const exp = JSON.parse(Buffer.from(control.presentation.split('~')[0].split('.')[1], 'base64url').toString('utf8')).exp;
for (const id of ['accept-x5c', 'disclosed-exp']) {
const v = sd.vectors.find((x) => x.id === id);
setNow((exp + 86400) * 1000);
const r = await verdict(() =>
oe.verifyPresentation(v.presentation, sdQuery, { nonce: s.nonce, audience: s.audience, kbJwtMaxAge: 10 * 365 * 86400, ...sdTrust(v) })
);
console.log(`clock one day after exp ${exp}: ${id} -> ${r.valid === true ? 'accept' : 'reject'}${r.valid === true ? '' : ` (${r.parsed?.error ?? ''})`}`);
}Output at 0.18.0:
@openeudi/openid4vp 0.18.0, node v24.22.2
claim-overwrite: expected reject, got accept; returned given_name "Erika"
disclosed-exp: expected reject, got accept
disclosed-iss: expected reject, got accept
disclosure-named-sd: expected reject, got accept
x5c-includes-anchor: expected reject, got accept
md-devicesig-not-detached: expected reject, got accept
md-devicesigned-unauthorized-ns: expected reject, got accept
md-issuerauth-crit-unknown: expected reject, got accept
md-issuerauth-dup-label: expected reject, got accept
md-mso-not-tag24: expected reject, got accept
controls accepted 5 of 5; must-refuse vectors refused 44 of 54
clock one day after exp 1822348800: accept-x5c -> reject (Issuer JWT signature verification failed)
clock one day after exp 1822348800: disclosed-exp -> acceptThe entry of each of the ten in the two files names what was changed and cites its clause. Each of the five mdoc vectors above carries a repaired twin (repaired.response in its entry) with that point corrected; the script does not run the twins.
Left to you. The preconditions are stated above and with each item.
Source: NVD
Free Vulnerability Assessment
Evaluate your cloud security practices across 9 security domains to benchmark your risk level and identify gaps in your defenses.
Get a personalized demo
"Best User Experience I have ever seen, provides full visibility to cloud workloads."
"Wiz provides a single pane of glass to see what is going on in our cloud environments."
"We know that if Wiz identifies something as critical, it actually is."