The issuer key (a public JWK, or the public keys of an AERE identity) is checked first: one that cannot be read, is private, or is not a key for the algorithm asked exits 2 with the reason, instead of reporting the token INVALID. --json prints compact JSON (indented output grew with the square of the claims' nesting depth). --at judges at a given time, as for verify. Test on the RFC 9901 example presentation: valid at its own time, invalid now, a private key refused. Tests: SD-JWT 23/23, negative control 28/28; identity 44/44, negative control 49/49.
207 lines
18 KiB
Markdown
207 lines
18 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), below.
|
|
|
|
```
|
|
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.
|
|
|
|
```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, not against another SD-JWT library (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. The ML-DSA JOSE names come from the IETF draft `draft-ietf-cose-dilithium`, not yet a published standard, so a verifier
|
|
that does not know them rejects an ML-DSA-65 SD-JWT; for today's libraries, 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`, `...` or `__proto__`, or
|
|
one that overwrites a claim, or a claim named `__proto__` anywhere, is refused; 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:
|
|
SD-JWT VC (no `vct`, no type metadata), and no status list inside the token (revocation stays with the AERE credential's list).
|
|
Checked against the RFC's vectors, not against another SD-JWT library: that is not measured.
|
|
|
|
## 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 # 44: the paths above, and each attack of the adversarial review as its own test
|
|
node control-negativ-identity.mjs # on a copy, each of 49 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 # 23: the RFC 9901 vectors, SD-JWTs issued with AERE keys (EdDSA and ML-DSA-65), each attack
|
|
node control-negativ-sdjwt.mjs # on a copy, each of 28 guards removed -> its own named test turns red
|
|
```
|
|
|
|
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.
|