100 lines
7.3 KiB
Markdown
100 lines
7.3 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).
|
|
|
|
```
|
|
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.
|