# 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 --out presentation.json node identity-cli.mjs verify --presentation presentation.json --audience https://shop.example --nonce \ --trust-issuer --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 ` 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/\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 \ --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 --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 --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 \ --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=`); without it that test is reported as not measured and the suite exits 2. No third party has reviewed any of this.