| .. | ||
| interop | ||
| conformitate.mjs | ||
| control-negativ-conformitate.mjs | ||
| control-negativ-identity.mjs | ||
| control-negativ-sdjwt-vc.mjs | ||
| control-negativ-sdjwt.mjs | ||
| control-negativ-travel-rule.mjs | ||
| fixturi-rfc9901.json | ||
| identity-cli.mjs | ||
| identity.mjs | ||
| proba-conformitate.mjs | ||
| proba-identity.mjs | ||
| proba-sdjwt-vc.mjs | ||
| proba-sdjwt.mjs | ||
| proba-travel-rule.mjs | ||
| README.md | ||
| sdjwt-vc.mjs | ||
| sdjwt.mjs | ||
| travel-rule.mjs | ||
Aere Identity
Post-quantum credentials with selective disclosure, bound to the holder's key, with delegation to devices, agents and short-lived
session keys, revocation and an issuer status list. Everything verifies offline, by anyone, from the files alone. Node.js 24, no
dependencies (node:crypto provides Ed25519 and ML-DSA-65, FIPS 204). The same keys also issue and verify standard SD-JWT (IETF
RFC 9901) and SD-JWT VC (the IETF draft), below, measured against other implementations in both directions.
node identity-cli.mjs keygen --out issuer.keys.json
node identity-cli.mjs keygen --out holder.keys.json
node identity-cli.mjs pub --keys holder.keys.json --out holder.pub.json
node identity-cli.mjs issue --issuer-keys issuer.keys.json --holder-pub holder.pub.json --type MemberCredential \
--claim member=true --claim tier=gold --claim org=Example --disclosable member,tier \
--status-list urn:example:status:1 --status-index 5 --out cred.json # give cred.json to the holder only
node identity-cli.mjs status-list --issuer-keys issuer.keys.json --id urn:example:status:1 --revoke 7 --out list.json
node identity-cli.mjs present --cred cred.json --reveal member --presenter-keys holder.keys.json \
--audience https://shop.example --nonce <the verifier's nonce> --out presentation.json
node identity-cli.mjs verify --presentation presentation.json --audience https://shop.example --nonce <nonce> \
--trust-issuer <issuer id> --status-list list.json
verify prints one line per check (ok, FAIL, or -- for not judged, with the reason) and VALID or INVALID; it exits 0 on
VALID, 1 on INVALID, 2 on a usage error (a broken --trust-issuer or --max-age included). The claims it returns are the plain ones
plus the ones the holder chose to show. --json prints the whole result as one object, with subject (type, credential, issuer,
holder, presenter, number of delegations) when no check failed. --at <RFC 3339 UTC time> judges on that clock instead of now: the
clock is the verifier's in any case, and a verdict given at one moment (by a service, for instance) can then be checked again later
with the same result.
What is signed, and by whom
Signatures are hybrid: Ed25519 and ML-DSA-65 over the same message, and both are required, so a break of either scheme alone
forges nothing. Every message is domain-separated by purpose (aere-identity/v1/<purpose>\n + text, purposes credential,
presentation, delegation, revocation, status-list, and for the Travel Rule kem-binding, travel-rule, travel-rule-receipt),
so a signature made for one purpose is not valid for another over the same text. The signed text is the canonical JSON of the statement (keys sorted), rebuilt by the verifier.
An identity is its keys: aere-id: + the first 20 bytes of SHA-256 over the canonical form of the two public keys (SPKI). A
credential or a delegation that names an id not derived from the keys it carries fails.
Selective disclosure works the way SD-JWT does (IETF RFC 9901), in a format of its own: each disclosable claim becomes
[salt, name, value], base64url-encoded; the issuer signs only the SHA-256 digests of those encodings (the sd list, sorted, with
optional decoy digests so the number of claims is hidden), and the holder shows only the ones it picks. Claims that are not disclosable
sit in the clear. This is not SD-JWT: the signatures are hybrid and the encoding is canonical JSON, so SD-JWT wallets do not read it
(for standard SD-JWT from the same keys, see sdjwt.mjs below).
It is also not a zero-knowledge proof: a shown claim is shown whole, so a birth date shows the date; an issuer that wants "over 18"
issues age_over_18: true as its own claim.
A presentation is bound to one verifier: the presenter signs the credential's hash, the hash of the disclosures it shows, the
delegation chain, the verifier's audience and nonce, and the time. Without the verifier's own audience and nonce a presentation made
for someone else would be accepted, so verify reports those checks as not judged instead of passing them. A nonce is the
verifier's to make fresh and to accept once: a verifier that reuses one accepts a replay within the presentation's age limit.
Inputs the verifier fetched and cannot read (a malformed status list or revocation) are ignored and reported, never counted against the credential; a malformed trusted issuer key is the verifier's own error and stops the verification.
Delegation
A holder can let another key present its credentials: a phone, an AI agent, a session key valid for ten minutes.
node identity-cli.mjs delegate --from-keys holder.keys.json --to-pub phone.pub.json --credentials <credential id> \
--claims member,tier --audiences '*' --valid-minutes 1440 --max-depth 1 --out phone.delegation.json
node identity-cli.mjs delegate --from-keys phone.keys.json --to-pub session.pub.json --parent phone.delegation.json \
--credentials <credential id> --claims member --audiences https://shop.example --valid-minutes 10 --out session.delegation.json
node identity-cli.mjs present --cred cred.json --reveal member --presenter-keys session.keys.json \
--delegation phone.delegation.json --delegation session.delegation.json --audience https://shop.example --nonce <nonce> --out p.json
node identity-cli.mjs revoke --keys holder.keys.json --delegation phone.delegation.json --out revocation.json
Each link is signed by whoever gives it, names its parent link by hash, and can only narrow: its scope (which credentials, which
disclosable claims, which audiences) is inside the parent's, its window inside the parent's window, its depth below the parent's. The
first link is given by the credential's holder, and the presenter must be the last delegate. A link is revoked by a statement signed by
whoever gave it or by the holder; it applies once its time has passed on the verifier's clock, and verify takes the revocations it is
handed with --revocation (a revocation it was not handed cannot be seen, and the verdict says so).
Status list
The issuer's status list follows W3C Bitstring Status List v1.0: a bitstring (bit 0 is the most significant bit of the first byte), gzip-compressed, signed by the issuer, with a validity window; the credential names the list and its index. A list that is missing, signed by someone else, or outside its window leaves the status not judged, never "not revoked". With several current lists of the issuer, a bit set in any of them means revoked. A list is decompressed with a ceiling at its declared size (at most 2^24 bits).
Time, said exactly
issuedAt, validFrom and validUntil are the issuer's statements; the presentation time is the presenter's, bounded by the
verifier's clock (--max-age, default 300 s, both ways); a revocation time is the revoker's. Everything is judged on the verifier's
clock, which is never exactly the issuer's: starts (a credential's or a status list's validFrom, a delegation's notBefore) are
accepted up to 60 s in the verifier's future (clockSkewS), and a revocation dated up to 60 s ahead already applies; ends
(validUntil, notAfter) get no allowance, since that would extend a validity. (Measured 2026-09-30: without it, a credential issued
on a machine whose clock was a second ahead was "not yet valid" at a verifier synchronized by NTP.) For a time the issuer does not choose, notarize: proofOfCredential and proofOfDelegation (in identity.mjs) build AIP-23
envelopes (identity and authorization kinds of ../proof-kinds) that the Aere Proof API notarizes and that the AIP-23 reference
verifier checks.
Compliance without surveillance (conformitate.mjs)
A verifier writes its compliance policy once: which issuers it believes, and which claims it needs (equals, in, notIn,
atLeast, present), for example "over 18, not in these jurisdictions, verification level at least 2". checkCompliance judges a
presentation against it: the presentation must be valid and bound to the verifier's audience and nonce, the issuer must be one of
the policy's, the credential status must be judged, not merely "not known to be revoked" (unless the policy says otherwise), and
every rule must hold. The policy's hash is recomputed from its normal form, so a looser policy cannot pass under a strict one's hash.
node identity-cli.mjs comply --presentation p.json --policy policy.json --audience https://exchange.example --nonce <nonce> \
--status-list list.json --record record.json [--pseudonym-key-file key]
comply --json [--with-record] [--at T] prints the result and, with --with-record, the record in one object; with --at the
record's time is T too, so the same command gives the same record byte for byte. It exits 0 compliant, 1 not compliant, 2 when the
policy or an option is refused (with the reason). Aere Cloud runs this command line, unmodified, behind POST /v1/identity/verify
and POST /v1/compliance/check, and each answer names the SHA-256 of the files that judged and the command, with --at, that
reproduces it.
The record (complianceEnvelope) is an AIP-23 compliance envelope that carries no personal data: the policy's hash, the result, the
digest of the presentation's signed binding (which names, by hash, the credential, the disclosures, the delegation chain, the
audience, the nonce and the time; an unsigned field added to the presentation does not change it), and a pseudonym of the holder
bound to this verifier. Without --pseudonym-key-file the pseudonym is a
SHA-256 over the holder's id and the audience, which anyone who knows both can recompute (it keeps the id out of the record, it does
not hide it from them); with a key it is an HMAC only the verifier can recompute. The verifier keeps the record, and can notarize it
for a time it does not choose, instead of keeping the data. What this is not: a zero-knowledge proof (a shown claim is shown whole;
"over 18" is a claim the issuer made), an AML or sanctions screening (it consults no list), or legal compliance with anything: it
says that one presentation met one policy at one time, as judged by the verifier.
Travel Rule between VASPs, post-quantum (travel-rule.mjs)
The originator and beneficiary data of a transfer (an IVMS101 object) sent from the paying VASP to the receiving VASP, readable only
by it: a hybrid key encapsulation (X25519 and ML-KEM-768, both secrets required, HKDF-SHA-256, AES-256-GCM), signed by the originator
(Ed25519 and ML-DSA-65), bound to the transfer (chain, asset, amount, beneficiary address, transaction) as authenticated data and in
the key derivation, and acknowledged with a signed receipt. Each side proves it is a VASP with a presentation of an Aere Identity
credential (vasp: true) from a registry the other side trusts, made for the other side, with its status judged.
import { generateKemKeys, bindKemKeys, acceptBeneficiary, sealMessage, openMessage, acknowledge, verifyReceipt, travelRuleRecord } from './travel-rule.mjs';
// beneficiary: its KEM keys, bound to its identity (signed), handed over with a presentation of its VASP credential for the originator
// originator: acceptBeneficiary({ binding, presentation, registries, originatorId, nonce, statusLists }) -> { ok, beneficiary }
// sealMessage({ from, beneficiary, ivms101, transfer, presentation /* its own, for the beneficiary, nonce = messageId */ })
// beneficiary: openMessage(message, { me, kem, registries, statusLists, seen }) -> { ok, ivms101, transfer } ; acknowledge(...)
// originator: verifyReceipt(receipt, { message, beneficiaryId }) ; travelRuleRecord({ message, receipt, buildProof })
openMessage refuses a message not signed by the originator it names, sealed for another VASP or other keys, older or newer than 300
seconds on its clock, already received, or whose originator is not proven a VASP. The replay store seen is required (a Set, or
any object with has and add, that keeps the ids for at least the 300 seconds): without one, openMessage opens nothing, since
the same signed and sealed message could otherwise be opened any number of times. travelRuleRecord writes an AIP-23
compliance envelope with no personal data and no amount: a pseudonymous transfer reference, the policy name, the result and a digest
of the exchange. What it does not do: validate the IVMS101 schema (it carries the object as given and requires originator and
beneficiary), find the beneficiary's VASP from an address (that is a discovery protocol's job), or say that a transfer is lawful.
SD-JWT, the IETF standard (sdjwt.mjs)
The same issuer and holder keys, in the format of the standard: Selective Disclosure for JSON Web Tokens, IETF RFC 9901 (November 2025), compact serialization. Checked against the RFC's own vectors and, since 2026-10-18, against another SD-JWT library in both directions (below).
import { issueSdJwt, presentSdJwt, verifySdJwt } from './sdjwt.mjs';
// issuer: an issuer-signed JWT (digests of the disclosable claims in _sd, list elements as {"...": digest}, _sd_alg sha-256, the holder's
// key in cnf.jwk) followed by the disclosures, joined by ~
const { sdJwt } = issueSdJwt({ issuer, holder: holderPublicJwkOrKeys, claims, disclosable: ['given_name', 'age_over_18'],
arrayDisclosable: ['nationalities'], iss: 'https://issuer.example', exp, alg: 'EdDSA' }); // or alg: 'ML-DSA-65'
// holder: keep only the chosen disclosures and add a Key Binding JWT (typ kb+jwt, aud, nonce, iat, sd_hash) signed with the holder key
// a name reveals a top-level claim; { element, in } an element of the top-level list named (only that list)
const shown = presentSdJwt({ sdJwt, reveal: ['age_over_18', { element: 'RO', in: 'nationalities' }], holder, audience, nonce });
// verifier: the steps of RFC 9901 sections 7.1 and 7.3, with the issuer key the verifier chose (never one taken from the token);
// its audience and nonce are required, exp is required, the token must be explicitly typed ...+sd-jwt (expectedTyp to change it)
const r = verifySdJwt(shown, { issuerKey, audience, nonce, expectedIssuer: 'https://issuer.example' }); // { valid, reason, payload, disclosed, keyBinding }
From the command line (the issuer key a public JWK, or the output of pub): node identity-cli.mjs verify-sdjwt --sd-jwt token.txt --issuer-key issuer.pub.json --issuer-alg EdDSA --audience A --nonce N [--expected-issuer I] [--at T] [--json]; Aere Cloud runs it behind POST /v1/identity/sd-jwt/verify.
Checked against the standard's own vectors (fixturi-rfc9901.json, extracted from RFC 9901 Section 5 and Appendix A.5; IETF code
components, Revised BSD License): the digest of each of the ten example disclosures; the example SD-JWT, signed ES256 by someone
else with the key in A.5, verifies and rebuilds every input claim; the example presentation with its Key Binding JWT verifies and
gives exactly the processed payload printed in the RFC; the same presentation for another audience, another nonce, an hour late,
with a disclosure removed or added after key binding, or with another issuer key, is refused.
Algorithms: ES256 (the RFC examples), EdDSA / Ed25519 (RFC 8037; the key every AERE identity has) and ML-DSA-65 with the JWK key
type AKP (alg ML-DSA-65, parameters pub and priv), the names and key form of IETF RFC 9964 (May 2026; until then the draft
draft-ietf-cose-dilithium, which this file quoted as not yet a standard). Measured 2026-10-18 on Node.js 24.14.1: jose 6.2.12 signs
and verifies ML-DSA-65 JWS through Node's WebCrypto (which prints an experimental-feature warning), its signatures and ours verify
each other, and @sd-jwt/core 0.23.0 accepts an ML-DSA-65 SD-JWT and KB-JWT through its injected verifier; a library that does not
know RFC 9964 rejects such a token, so for it issue with EdDSA. A hybrid (two signatures in one token) would need the general JWS JSON
serialization and is not done here. The algorithm must match the key the verifier gives (no none, no algorithm confusion); a
disclosure not bound to a digest, a digest seen twice, a disclosure naming _sd, ..., _sd_alg or __proto__, or one that
overwrites a claim, or a claim named __proto__ anywhere, is refused; _sd_alg below the top level of the payload is refused (RFC
9901 Section 4.1.1: it MUST NOT be nested; until 2026-10-18 it was kept as an ordinary claim); the typ value may carry the
application/ prefix or not (RFC 7515 Section 4.1.9); the issuer refuses claim values that already carry SD-JWT structure (_sd,
_sd_alg, {"...": digest}), which would let a holder disclose claims the issuer never saw; exp gets no allowance and nbf/iat
get 60 s, as for AERE credentials. What it is not: the SD-JWT VC profile is sdjwt-vc.mjs, below; a plain SD-JWT carries no status
list (the AERE credential has its own list, and the SD-JWT VC profile reads a Token Status List).
Interoperability, measured 2026-10-18 (interop/interop-sdjwt.mjs, run with the foreign libraries installed in a folder outside
this repository; Node.js 24.14.1; @sd-jwt/core 0.23.0 of openwallet-foundation-labs/identity-common-ts with @owf/identity-common
0.5.0, @sd-jwt/crypto-nodejs 0.19.0 as its hasher and salt generator, jose 6.2.12 as its JWS engine, its keys made in WebCrypto):
26 of 26 rows gave the verdicts expected of both sides. Ours to theirs: an SD-JWT issued and presented here (EdDSA, ES256 and
ML-DSA-65; top-level claims, list elements, two decoy digests, a Key Binding JWT with the verifier's audience and nonce) verifies
there with the same processed payload; a changed disclosure, a KB-JWT made for another audience, alg none, a digest listed
twice, a KB-JWT signed by another key than cnf.jwk and a nested _sd_alg are refused by both, each with its reason. Theirs to
ours: an SD-JWT issued and presented by @sd-jwt/core (nested disclosures such as address.locality, list elements, decoys, a
KB-JWT) verifies here with the same payload for the three algorithms, untyped with expectedTyp: null and typed example+sd-jwt
under the default, and the same tampered forms plus a disclosure removed after key binding are refused by both. The tokens they
produced are kept as fixtures (interop/fixturi-interop-sdjwt.json, 11 cases, with their public test keys) and proba-sdjwt.mjs
judges them offline at the recorded clock; the row-by-row table is interop/rezultate-interop-sdjwt.json. Differences found, and
what was decided: @sd-jwt/core puts no typ on an issuer-signed JWT unless told to (SDJwtVcInstance puts dc+sd-jwt) and never
checks it; our verifier requires an explicit ...+sd-jwt type by default (RFC 9901 Section 9.11 recommends that verifiers check it)
and takes an untyped token with expectedTyp: null: kept. Their verifier compares the KB-JWT audience only when given
expectedKeyBindingAudience, and does not look at a KB-JWT at all when no keyBindingNonce is given; ours requires the verifier's
audience and nonce whenever a KB-JWT is present or required (Section 7.3 step 5.f): kept. Their issuer takes any secure hash of
the IANA list for _sd_alg (sha-384, sha-512, ...); ours understands only sha-256 (Section 4.1.1 requires it and leaves the
rest to policy): kept, measured as our refusal of their sha-384 token. Nested _sd_alg: they refused it and we kept it as a
claim; the RFC's MUST NOT decided it, repaired here. The salt of their default helper, generateSalt(16) of @sd-jwt/crypto-nodejs,
is 16 hexadecimal characters, 64 bits, under the 128 bits Section 9.3 recommends; ours is 16 random bytes; theirs, and the generator
is injectable. Disclosures are ordered by the presentation frame there and by issuance order here; the RFC orders them not at all.
An AKP JWK without alg is refused by Node's crypto and by jose alike (RFC 9964 Section 3 requires it).
SD-JWT VC (sdjwt-vc.mjs)
The profile "SD-JWT-based Verifiable Digital Credentials", IETF draft-ietf-oauth-sd-jwt-vc-19 (31 August 2026, submitted to the
IESG, not an RFC), as a thin layer over sdjwt.mjs, with the Token Status List of draft-ietf-oauth-status-list-21 (21 June 2026)
read offline. No dependencies.
import { issueSdJwtVc, presentSdJwtVc, verifySdJwtVc, issueStatusListJwt, readStatusListJwt } from './sdjwt-vc.mjs';
const { sdJwt } = issueSdJwtVc({ issuer, holder, vct: 'urn:eudi:pid:1', iss, claims, disclosable, arrayDisclosable, exp,
status: { status_list: { idx: 5, uri: 'https://issuer.example/status/1' } } }); // typ dc+sd-jwt; vct, iss, cnf, nbf, exp, status in the clear
const shown = presentSdJwtVc({ sdJwt, reveal: ['birthdate'], holder, audience, nonce });
const list = issueStatusListJwt({ issuer, uri, statuses: [0, 0, 0, 0, 0, 1], bits: 1, exp }); // typ statuslist+jwt, sub = uri, iat, status_list { bits, lst }
const r = verifySdJwtVc(shown, { issuerKey, audience, nonce, expectedVct: 'urn:eudi:pid:1', statusListJwt: list });
// r: { valid, reason, payload, disclosed, keyBinding, vct, status: { present, judged, uri, idx, value, name } }
What the verifier requires beyond RFC 9901: the header type dc+sd-jwt (Section 2.2.1); a vct in the payload equal to one the
verifier names (expectedVct is required: a verifier that does not say which credential type it expects would accept any); none of
iss, nbf, exp, cnf, vct, vct#integrity, aka_vcts, status selectively disclosed (Section 2.2.2.3; the issuer refuses to
make them disclosable); exp by default, as for a plain SD-JWT. A status claim is judged from the Status List Token the verifier
fetched from the uri the credential names and hands in (statusListJwt; signed with the issuer's key unless statusListKey says
otherwise): its signature, its typ, sub equal to that uri, iat, exp, then the status at idx (0 valid; 1 revoked, 2
suspended and any other value refuse; the bits are packed from the least significant bit, 1, 2, 4 or 8 per entry). A credential
that names a status nobody judged is refused by default (requireStatus: false accepts it and reports the status as present and
not judged); one that names no status is judged without a list. Nothing is fetched: not the list, not the type metadata of vct
(vct#integrity is carried, not resolved), and the issuer's key is the verifier's input, never resolved from iss (Section 2.5:
JWT VC Issuer Metadata, x5c) or taken from the token.
From the command line: node identity-cli.mjs verify-sdjwt-vc --sd-jwt token.txt --issuer-key issuer.pub.json --audience A --nonce N --expected-vct urn:eudi:pid:1 [--status-list list.txt [--status-list-key k.json]] [--status-not-required] [--expected-issuer I] [--at T] [--json]; 0 VALID, 1 INVALID, 2 a usage error.
Interoperability, measured 2026-10-18 (interop/interop-sdjwt-vc.mjs; @sd-jwt/sd-jwt-vc 0.23.0, @owf/token-status-list 0.5.0,
jose 6.2.12, Node.js 24.14.1): 28 of 28 rows gave the verdicts expected of both sides. Ours to theirs: an SD-JWT VC issued here
(EdDSA, ES256, ML-DSA-65; vct urn:eudi:pid:1, a status_list reference) with our Status List Token served to their
statusListFetcher verifies in SDJwtVcInstance with expectedVct, the key binding audience and nonce; our list with the index
revoked, or suspended on 2 bits, our list published for another uri, a wrong expected vct and a KB-JWT for another audience are
refused by both; our lists on 1, 2 and 8 bits are read by their decoder index by index, and theirs by ours (12 mixed statuses on 2
bits, written equals read, both ways). Theirs to ours: their credential and their status list verify here under the default type,
and their revoked, suspended, other-uri and expired lists, a wrong vct, a KB-JWT for another audience and a credential they issued
without vct are refused here with the reason. Their tokens and lists are fixtures (interop/fixturi-interop-sdjwt-vc.json, 12
cases) that proba-sdjwt-vc.mjs judges offline. Differences: their issuer does not require vct and their verifier checks it only
when given expectedVct; here both are required. Their verifier skips the status when told (disableStatusVerification) and
otherwise fetches it over the network; here nothing is fetched and an unjudged status is a refusal unless waived. Both refuse to
make vct selectively disclosable.
The European wallet (ARF) and the PID, read, not claimed. Read on 2026-10-18: the Architecture and Reference Framework v3.0.0
(21 July 2026; Annex 2.02, the high-level requirements by topic), the PID Rulebook v1.7 (17 July 2026, in
eu-digital-identity-wallet/eudi-doc-attestation-rulebooks-catalog), the OpenID4VC High Assurance Interoperability Profile 1.0
(24 December 2025), draft-ietf-oauth-sd-jwt-vc-19 and draft-ietf-oauth-status-list-21. This module claims no EUDI conformance:
the table says, row by row, what is measured here.
| requirement | where it is written | here |
|---|---|---|
| SD-JWT VC as one of the two common attestation formats, compact serialization | ARF ARB_01; HAIP 6.1 | met; measured against @sd-jwt/sd-jwt-vc in both directions |
typ dc+sd-jwt |
sd-jwt-vc-19 Section 2.2.1 | met (issued and required) |
vct in the urn:eudi:pid: namespace, urn:eudi:pid:1 for the PID |
PID Rulebook PID_14, Section 4.2; ARF ARB_05 | met as a mechanism: the issuer sets vct, the verifier names the one it expects; the value is the issuer's |
the mandatory PID attributes (family_name, given_name, birthdate, place_of_birth, nationalities; the portrait later) and metadata (issuing_authority, issuing_country) |
PID Rulebook Sections 2.2, 2.4 | not met: claims pass through as data, no PID schema is checked |
| claim names from the IANA JWT registry, public or private names; UTF-8 | ARF ARB_06b; PID Rulebook Section 4.1.1 | not judged (JSON is UTF-8; names are not checked against the registry) |
| every claim selectively disclosable on its own | PID Rulebook PID_21; ARF ARB_30 | met for top-level claims and list elements; not for the properties of nested objects at issuance (the verifier reads nested disclosures, the issuer does not make them) |
key binding (cnf.jwk), a KB-JWT always present when the credential is bound |
ARF OIA_02; HAIP 6.1.1.1; sd-jwt-vc-19 Section 2.2.2.3 | met (key binding required by default; cnf never disclosable) |
revocation by Token Status List (status.status_list) |
ARF VCR_01b, VCR_11a; HAIP 6.1; status-list-21 | met offline: the verifier hands in the list it fetched; measured against @owf/token-status-list; an unjudged status refuses by default |
| a Relying Party also supports Attestation Revocation Lists | ARF VCR_12 | not met (nothing implemented; VCR_11a notes there is no JSON specification) |
validity by nbf and exp |
PID Rulebook Section 4; HAIP 6.1 | met (exp required by default) |
| algorithms only from the ECCG Agreed Cryptographic Mechanisms v2.0; ES256 at least | ARF OIA_03; HAIP 7 | ES256 met; EdDSA and ML-DSA-65 are offered too, and their standing in ACM v2.0 was not read: not measured |
hash sha-256 |
HAIP 8; RFC 9901 Section 4.1.1 | met (the only one understood) |
issuer key from x5c (a trust chain, never self-signed) or from JWT VC Issuer Metadata |
HAIP 6.1.1; sd-jwt-vc-19 Section 2.5 | not met: the key is the verifier's input |
type metadata for vct, published in a catalog; vct#integrity |
PID Rulebook PID_15; sd-jwt-vc-19 Section 5 | not met: carried, not resolved |
| presentation over OpenID4VP | ARF ARB_02 note | outside: this is a credential format, not a protocol |
| mdoc (ISO/IEC 18013-5) for proximity; wallets support both formats | ARF ARB_02, ISSU_02 | outside, not today: it would need CBOR (RFC 8949) and COSE (RFC 9052) encoding, the mobile security object, device authentication and the session encryption of ISO/IEC 18013-5 |
Named rules
Since 2026-10-05 every refusal of verifyPresentation names its rule. A row is { name, pass, detail, rule }; a row that was not
judged is { name, pass: null, detail, unjudged } with its cause. The result carries rules, the sorted ids of the rules that
refused, and unjudged, the sorted causes of what was not judged. The module exports the lists: REGULI (37 rules), NEJUDECATE
(7 causes) and OPRIRI (the four rules after whose refusal other checks were not made: form, chain-length, delegation-form,
unreadable). A time field that cannot be read (a credential's validity, a delegation's or a presentation's bounds) and a malformed
earlier link are reported as unreadable, which is one of the stopping rules: the checks after it are not made. The verifier does
not stop at its first refusal: it makes every check it can reach. valid is unchanged: true when
no row failed, also when rows were not judged; a caller that needs "nothing left unjudged" reads unjudged.
With these ids the presentation verifier is enrolled in the conformance suite beside this folder (conformance/: 37 rules,
65 vectors of the artifact kind identity-presentation, each rule taken out of a copy of the verifier in turn). Since 2026-10-18 the
SD-JWT verifier and the SD-JWT VC profile are enrolled there too: verifySdJwt names the rule of its refusal (rule, one of the 23 of
REGULI in sdjwt.mjs: form, alg, typ, crit, key, signature, issuer, sd-alg, disclosure-form, disclosure-twice,
digest-twice, disclosure-name, claim-conflict, unreferenced, time, kb-context, kb-required, kb-cnf, kb-claims,
kb-audience, kb-nonce, kb-freshness, kb-sd-hash), and verifySdJwtVc one of those or one of its own 9 (vct-expected, vct,
vct-mismatch, disclosed-protected, form, status-list, status-index, status-revoked, status-unjudged); both stop at their
first refusal. The suite holds 54 vectors of the kind sdjwt-presentation (the RFC 9901 example, the tokens of @sd-jwt/core from the
interop fixtures, and tokens issued with AERE keys) and 32 of sdjwt-vc-presentation (ours with their status lists, and the credentials
and lists of @sd-jwt/sd-jwt-vc and @owf/token-status-list); a VC whose status was waived and not judged is PARTIAL there, never
VALID.
Since the same day the Travel Rule and the compliance verifiers are enrolled too, the last two of this folder. acceptBeneficiary,
openMessage and verifyReceipt return, beside ok and motive (unchanged), rules (the ids that refused, sorted) and rows
(every check, { rule, pass, detail }); the 23 rules are in REGULI of travel-rule.mjs (binding-form, binding-id,
binding-signature, binding-window, vasp-presentation, vasp-status, vasp-identity, vasp-claim, message-form,
message-id, message-signature, message-recipient, message-kem-keys, message-freshness, replay-store, replay, kem-alg,
kem-open, ciphertext, receipt-form, receipt-from, receipt-signature, receipt-message), and OPRIRI names the three after
which the verifier stopped reading. The VASP presentation inside is judged by verifyPresentation; its refusals are reported under
vasp-presentation. The header checks of a message are all made; at any refusal the message is not opened. Two behaviours changed:
a verifier clock that cannot be read refuses the message's freshness (before, it compared with NaN and passed that check; the
presentation inside still refused); and acceptBeneficiary called without originatorId or without nonce refuses the VASP
presentation (before, the identity verifier left its audience or nonce unjudged and the beneficiary came out accepted, with a
presentation that could be replayed to any originator; found by the malformed-input row of the conformance suite on 2026-10-18, now a
test of proba-travel-rule.mjs, a planting of its negative control and two vectors of the suite). checkCompliance returns, beside compliant and reasons (unchanged), rules (presentation,
status, rule; REGULI of conformitate.mjs), rows and unjudged (the causes verifyPresentation left unjudged). The suite
holds 19 vectors of the kind travel-rule-binding, 26 of travel-rule-message (judged with the beneficiary's KEM key pair, test
material made for those vectors and declared so at the scene), 9 of travel-rule-receipt and 12 of compliance-presentation; a
compliance judgement under a policy that waives the status, with no list read, is PARTIAL there.
Two things changed in behaviour that day, both measured by proba-identity.mjs, each with its planting in the negative control:
- found while the verifier was being enrolled: when the issuer keys in a credential could not be read (for example an Ed25519 key in
the
mldsa65field), the verifier also reported that the holder id is not the id of its keys. The two ids were derived in one step, and the first failure left the second underived. The credential was refused either way; the report named the holder for a fault of the issuer's keys. Each id is now derived on its own; - a
statusListsorrevocationsargument that is not a list is an error of the caller and throws, like atrustedIssuersthat is not a list; until then the first two came back as an unreadable presentation.
What it does not do
It does not bind a key to hardware: a device key is a key like any other, and no TPM or secure-enclave attestation is checked here. It
does not rotate or recover keys. It does not say who an identity is in the world: the issuer says that, and the verifier chooses which
issuers it believes (--trust-issuer). Without --trust-issuer, anyone can issue a credential with their own keys, and the issuer
line is reported as not judged. The private key files are written with mode 0600, which Windows does not apply.
Tests
node proba-identity.mjs # 46: the paths above, each attack of the adversarial review as its own test, the named rules
node control-negativ-identity.mjs # on a copy, each of 53 guards removed -> its own named test turns red
node proba-conformitate.mjs # 15: compliance policies judged on real presentations, the record without personal data, the command line
node control-negativ-conformitate.mjs # on a copy, each of 14 guards removed -> its own named test turns red
node proba-travel-rule.mjs # 19: two VASPs with registry credentials, the whole exchange, and each attack of the review
node control-negativ-travel-rule.mjs # on a copy, each of 19 guards removed -> its own named test turns red
node proba-sdjwt.mjs # 25: the RFC 9901 vectors, SD-JWTs issued with AERE keys (EdDSA and ML-DSA-65), each attack, the fixtures of @sd-jwt/core
node control-negativ-sdjwt.mjs # on a copy, each of 31 guards removed -> its own named test turns red
node proba-sdjwt-vc.mjs # 10: the SD-JWT VC profile, the status list, the fixtures of @sd-jwt/sd-jwt-vc and @owf/token-status-list, the command line
node control-negativ-sdjwt-vc.mjs # on a copy, each of 16 guards removed -> its own named test turns red
node interop/interop-sdjwt.mjs --lab <folder> # the measurement itself, with the foreign libraries installed in <folder>, outside this repository
node interop/interop-sdjwt-vc.mjs --lab <folder> # (npm: @sd-jwt/core, @sd-jwt/sd-jwt-vc, @sd-jwt/crypto-nodejs, jose); --scrie rewrites the fixtures
The envelope test needs the AIP-23 reference verifier (AERE_VERIFY_PROOF=<verify-proof.mjs>); without it that test is reported as
not measured and the suite exits 2. No third party has reviewed any of this.