375 lines
36 KiB
Markdown
375 lines
36 KiB
Markdown
# 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.
|
|
|
|
**A presentation can also be bound to one request** (since 2026-10-18): `present({ ..., request })` puts `request`, the digest of
|
|
what the holder asks the verifier to do (`0x` + 64 lowercase hex, computed by the verifier's own rule from the request), into the
|
|
binding before it is signed, and `verifyPresentation(p, { ..., request })` then requires exactly that digest (rule `request`: another
|
|
digest, or none at all, is a refusal). Without it the signature covers who the presentation is for, but not what it is used for: a
|
|
presentation made for "read secret db" was handed over as "revoke a grant on db" (the adversarial review of the secrets service).
|
|
A verifier that gives no request leaves a binding that carries one **not judged** (cause `request`), like the audience and the nonce;
|
|
a binding without one, judged by a verifier that gives none, has no request row at all, so every presentation made before the field
|
|
existed verifies exactly as before. The model is `transaction_data` in OpenID for Verifiable Presentations 1.0 (Final, 9 July 2025):
|
|
section 5.1 (the request parameter), section 8.4 (the data signed with the same key as the proof of possession) and appendix B.3.3
|
|
(the `transaction_data_hashes` of the SD-JWT VC Key Binding JWT, SHA-256 by default). `checkCompliance` passes `request` through, and
|
|
takes a `cache` (a `Map` made by the caller for one presentation) so that a service judging the same presentation against many
|
|
policies verifies its signatures once per distinct pair of trusted issuers and maximum age, with the same result as without it.
|
|
|
|
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.
|
|
|
|
```js
|
|
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).
|
|
|
|
```js
|
|
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.
|
|
|
|
```js
|
|
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` (38 rules since 2026-10-18, with `request`), `NEJUDECATE`
|
|
(8 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/`: 38 rules,
|
|
69 vectors of the artifact kind `identity-presentation`, four of them for `request` since 2026-10-18, 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 `mldsa65` field), 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 `statusLists` or `revocations` argument that is not a list is an error of the caller and throws, like a `trustedIssuers` that
|
|
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 # 52: 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 57 guards removed -> its own named test turns red
|
|
node proba-conformitate.mjs # 18: compliance policies judged on real presentations, the record without personal data, the command line
|
|
node control-negativ-conformitate.mjs # on a copy, each of 17 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.
|