aere-quantum/identity
2026-09-30 09:18:53 +03:00
..
control-negativ-identity.mjs identity: post-quantum credentials with selective disclosure, delegation that can only narrow, revocation and an issuer status list, checked offline from the files 2026-09-30 09:18:53 +03:00
identity-cli.mjs identity: post-quantum credentials with selective disclosure, delegation that can only narrow, revocation and an issuer status list, checked offline from the files 2026-09-30 09:18:53 +03:00
identity.mjs identity: post-quantum credentials with selective disclosure, delegation that can only narrow, revocation and an issuer status list, checked offline from the files 2026-09-30 09:18:53 +03:00
proba-identity.mjs identity: post-quantum credentials with selective disclosure, delegation that can only narrow, revocation and an issuer status list, checked offline from the files 2026-09-30 09:18:53 +03:00
README.md identity: post-quantum credentials with selective disclosure, delegation that can only narrow, revocation and an issuer status list, checked offline from the files 2026-09-30 09:18:53 +03:00

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).

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. The claims it returns are the plain ones plus the ones the holder chose to show.

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), 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. 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. 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.

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              # 43: the paths above, and each attack of the adversarial review as its own test
node control-negativ-identity.mjs    # on a copy, each of 46 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.