aere-quantum/pq-secrets/README.md

33 KiB

Aere PQ Secrets

A secrets service on top of Aere PQ KMS: named secrets with versions and metadata, each version encrypted under its own data key, the data key wrapped by the KMS in its hybrid envelope (X25519 + ML-KEM-768). Every request is authorized by an AERE identity the caller presents, never by the network it comes from, a bearer token or the host it runs on: a presentation of an Aere Identity credential, made for this service (its audience), with a nonce the service issued and accepts once, within a time limit, signed by the holder's key or by a delegate of the holder, and signed for this one request: the digest of the operation, the secret and the whole body is inside the signed binding, so a presentation made to read db opens nothing else. Every operation, allowed or refused, is an AIP-23 envelope (secret-access, built by proof-kinds) appended to the audit log of the verification layer sidecar, whose head can be signed and notarized on Aere Network.

Node.js 24 only, node:crypto (OpenSSL 3.5), node:http, node:fs. No dependencies. The KMS can run in the same process (AERE_KMS_ROOT_KEY) or be the pq-kms server reached over HTTP (AERE_KMS_URL + AERE_KMS_TOKEN).

What it is NOT

  • Not zero trust towards its own host. Zero trust here is towards the network and the caller: no address, token or TLS client certificate opens a secret, only a verified presentation. The process holds each data key in memory while it uses it, the service MAC key for the whole run, and, with the KMS in process, the KMS root key. Whoever controls the host, the process memory or the environment has the secrets. Nothing here attests the service's own runtime; that is Proof of runtime (runtime envelopes of the verification layer), to be bound to this service separately.
  • An audit envelope alone proves nothing. An entry carries no signature of the service or of the presenter: a secret-access envelope made up by hand is VALID for the reference verifier (which judges form and hash, not that the access happened), and buildProof refuses only values outside the kind's vocabulary. The weight is in the log: its head, signed and notarized outside the host (attest-head, notarize-head of the sidecar), fixes everything before it. Between two attested heads, whoever holds the host can append entries or remove the last ones (a get that happened can vanish, an access that did not can appear) and the chain still verifies; only a head published outside the host catches a rewrite of what it covers.
  • GET /v1/audit/head is public: anyone learns the number of operations so far (refusals included) and the current head. It verifies the whole log on each call (linear in its size).
  • metadata shows the whole access list: a holder with the metadata operation sees every grant of the secret, with its trusted issuers, its claim rules, its holders and who made it.
  • Without an HSM the root is a sealed file. The KMS root comes from the environment or from a file sealed by a PKCS#11 token (see the KMS README); the secrets service adds no key ceremony of its own. Its own file-integrity key is a KMS data key, wrapped on disk and opened through the KMS at start.
  • Not audited. No third party has reviewed this code, the identity verifier it relies on, or the KMS under it.
  • One process per data directory, and the only rate limit is on nonces. Opening a data directory takes service.lock (created exclusively, with the PID and the host); a second instance, in this process or another, is refused (DATA_DIR_LOCKED). A lock left by a process that no longer runs on this host is reported by its PID (DATA_DIR_LOCK_STALE) and taken over only when asked (staleLock: 'take', AERE_SECRETS_STALE_LOCK=take), never silently; the check of the PID cannot tell a reused PID from the old one, and two instances taking over the same stale lock at the same moment are not excluded. Nonces live in process memory (120 s, at most 10,000 outstanding, at most 32 outstanding per address of origin as the HTTP server sees it, 429 NONCE_ORIGIN_FULL above). What remains: an attacker with many addresses still fills the 10,000; behind a proxy every caller has the proxy's address, so the per-address cap becomes a cap for everyone (raise AERE_SECRETS_NONCE_PER_ORIGIN or limit at the proxy); and refused requests also write audit entries, so a flood fills the log.
  • Rollback is not detected. A data directory, or one secret file, put back to an older state that still carries a valid MAC is accepted (the same limit as the KMS). An audit log rebuilt from genesis is detected only against a head published outside the host (verify-log --attested, see the verification layer).
  • A revoked version is gone through this service, not from the world. revoke-version deletes the sealed value and the wrapped data key from the file; a copy of the file taken before, together with the KMS, still opens it. Rotating the KMS key does not re-wrap stored data keys (no rewrap here yet); raising the KMS key's min_decryption_version makes older versions unreadable.
  • The status lists and revocations are what the operator puts in trust/. The service reads them at every request and hands them to the identity verifier; it fetches nothing. A list that is missing or expired leaves the status unjudged, and an unjudged status is a refusal, for everyone, the admin included.
  • Time is still an oracle. A refusal says the same bytes whether the secret exists or not (below), but the time it takes is not made constant: measured on 2026-10-18, a stranger asking for a secret with three grants took a median of 9.0 ms and for a name that does not exist 9.1 ms (15 tries each; one hybrid verification per distinct pair of trusted issuers and maximum age among the grants, so secrets whose grants trust different issuers take measurably longer).

How a request is judged

  1. Form. The body is an object and the name matches ^[a-z0-9][a-z0-9_-]{0,63}$ and is not a Windows device name (con, prn, aux, nul, com0-com9, lpt0-lpt9). Otherwise request-form or secret-name. These two refusals are not audited and do not consume the nonce (they are not operations: the same presentation can be sent again to a valid name).
  2. Size. The whole body, the presentation included, is at most 32 levels deep and 20,000 values, measured without recursion before anything is put in canonical form (a list nested 30,000 times would otherwise overflow the stack). Otherwise request-form (REQUEST_TOO_COMPLEX), audited.
  3. Presentation form. The body carries a presentation with binding.nonce. Otherwise presentation-form, audited.
  4. The request digest (requestHash, below) is computed from the request received; a body that JSON cannot carry (a value given through the library) is request-form, audited.
  5. Nonce. The nonce must be one this service issued (POST /v1/nonce), not expired (120 s) and never seen before. It is consumed at first sight, whatever the verdict after it: a second request with the same presentation is refused even when the first one was refused. Otherwise nonce.
  6. Identity. verifyPresentation of the identity component, on its production path, with the service's audience, the nonce, the request digest, the service clock, the status lists and revocations of trust/, no trusted issuers yet (they belong to each grant) and a freshness of 300 s. Every refusal names the identity rule: request (signed for another request, or for none), audience (made for another service), freshness (older than the limit), presenter and presentation-signature (signed by a key that is not the holder's or the last delegate's), credential-revoked, delegation-revoked, delegation-claims, delegation-window, and the rest of its 38. Otherwise authorization, with identityRules naming them (they say nothing about the secret: the identity is judged before any grant).
  7. Grant. The candidates are the admin policy (every operation, every version) and the secret's grants that list the operation. Each candidate's policy is judged by checkCompliance of the identity component, the hybrid signatures verified once per distinct pair (trusted issuers, maximum age): the issuer must be one the policy names, the credential status must be judged (a grant cannot waive it: requireStatus: false is refused by name at write time), and every claim rule must hold on the claims shown. A grant with holders admits only those holders and their delegates; with delegates: false, the holder alone. The first compliant grant authorizes. None is one answer for every secret: UNAUTHORIZED, rule authorization, the fixed message "no grant admits this presentation for this operation on this secret (a secret that does not exist is refused the same way)", the same bytes whether the secret does not exist, has no grant that lists the operation, or has one whose rule failed. Which grants were judged and the rules each refused on go to the audit entry only (judged).
  8. The operation. Only now the service says whether the secret exists (secret-exists, to a caller a grant admits: for a name that does not exist, the admin only), whether the version exists (version-exists), whether the grant reads old versions (version-scope: a grant reads the current version unless it says versions: "any"), whether the version was revoked (version-revoked), and so on. A message names a version only when it is an integer.
  9. The audit entry, then the change, then the result. For an operation that changes the secret, the secret-access envelope is appended to the sidecar log and the log is fsync-ed before the secret file is written; if the entry cannot be written, nothing is changed and the result is withheld (AUDIT_WRITE_FAILED); if the file cannot be written after the entry, a second entry with the verdict failed and the same requestHash follows and the request is refused (STORAGE_WRITE_FAILED). So a crash leaves at most an entry without its change, never a change without its entry (the crash itself is not simulated by the tests; the order and the failed write are). For a read, the entry is written before the value leaves the process.

The whole cycle of one secret (load, judge, change, entry, write) runs under a mutex of that secret, so concurrent requests on one secret are serialized even when the KMS is remote and answers in between.

Every refusal names one of the service's rules, REGULI in secrete.mjs (request-form, secret-name, presentation-form, nonce, authorization, secret-exists, value-form, version-exists, version-revoked, version-scope, grant-form, grant-exists, grant-scope, grant-limit, kms, storage, internal); each check carries a /*PAZA:<rule>*/ marker on its condition, in the form the conformance suite reads.

The request a presentation is signed for

requestHash = sha256( canonical JSON of { v: 1, kind: "aere-secrets-request", service, operation, secret, body } )

service is the audience; operation one of put, get, metadata, rotate, revoke-version, grant, revoke, list; secret the name (null for list); body every field of the request body except presentation and those that are undefined (version, value, metadata, grants, grant, grantId, bytes, encoding, and any other: a field added by whoever did not sign changes the digest). value (for put) enters as the base64 of its bytes: through the library a Buffer, or a string taken as its UTF-8 bytes (valueEncoding: 'bytes', the default); over HTTP the base64 string as sent (valueEncoding: 'base64'). The canonical JSON is the identity component's (keys sorted, no spaces). requestHash is exported by secrete.mjs and is the one place both the client and the service compute it: the client passes it to present({ ..., request }) before signing, the service computes it from what it received and gives it to the identity verifier, whose rule request requires the binding to carry exactly that digest (absent or other: refused). A presentation made before the field existed carries none and is refused here.

The model, read on 2026-10-18: OpenID for Verifiable Presentations 1.0 (Final, 9 July 2025), transaction_data (section 5.1, the request parameter; section 8.4, the transaction data signed with the same key as the proof of possession of the credential) and appendix B.3.3, where the SD-JWT VC Key Binding JWT carries transaction_data_hashes (SHA-256 by default, over the string received). Here the verifier does not send the data to sign; it states the rule by which the holder computes it, and the digest of the request itself is what is signed.

Grants and policies

A secret's policy is a list of grants, at most 32 (grant-limit, at creation and at grant). A grant is:

{ "id": "deployers",
  "operations": ["get", "metadata"],
  "versions": "current",
  "policy": { "id": "deployers-v1", "trustedIssuers": ["aere-id:..."],
              "require": [{ "claim": "role", "equals": "deployer" }, { "claim": "tenant", "equals": "acme" }] } }

operations is a non-empty subset of put, get, metadata, rotate, revoke-version, grant, revoke; versions is current (default) or any; holders (optional) is a list of aere-id: identities, which admits them and any delegate they present through; delegates: false (optional) admits the holder alone; policy is a compliance policy of the identity component (trustedIssuers, require with equals, in, notIn, atLeast, present; maxAgeS up to 3600), normalized and hashed by definePolicy. The id admin is reserved (it names the admin policy in the log). Each stored grant carries createdBy, the verified holder who made it. The admin policy (AERE_SECRETS_ADMIN_POLICY) is such a policy on its own: whoever satisfies it may do anything, including creating secrets and granting.

A grant made by a holder who is not the admin is an attenuation of the grant through which the grant request was admitted (rule grant-scope, GRANT_NOT_ATTENUATED): its operations are among that grant's, without grant and revoke (only the admin hands administration on, so a grant with grant alone gives nothing); it reads old versions only if that grant does; it trusts only issuers that grant trusts; its policy keeps every rule of that grant unchanged (it may add rules), a maximum age no longer, and the status required; when that grant names holders it names holders among them; when that grant refuses delegates it refuses them too. A comparison that cannot be made safely (a rule written differently, even if stricter) is a refusal. A holder who is not the admin revokes only grants it made itself (GRANT_NOT_OWNED, grant-scope).

list is not a per-secret operation: it returns every name for the admin and, for anyone else, the names of the secrets with a grant that admits the presentation (for any operation); when none does, it is refused with one fixed message.

The audit entry

{ "v": 1, "kind": "aere-proof-of-secret-access",
  "presentationHash": "0x...", "policyHash": "0x...", "requestHash": "0x...",
  "service": "https://secrets.example", "secret": "db", "version": 2, "operation": "get",
  "verdict": "allowed", "rules": [], "grant": "deployers",
  "holder": "aere-id:...", "presenter": "aere-id:...", "createdAt": "2026-10-18T12:00:00.000Z" }

presentationHash is the SHA-256 of the canonical signed binding of the presentation (it names, by hash, the credential, the disclosures shown, the delegation chain, the audience, the nonce, the time and the request; the same rule as the compliance record of the identity component). requestHash is the digest of the request received (on a substitution it differs from the request the binding names). verdict is allowed, refused or failed (allowed and entered, then the change could not be written). On a refused entry, rules holds the service rule followed by the identity and compliance rules that refused, and judged lists each candidate grant with the rules it refused on. holder and presenter are written only when the presentation's signatures were verified (the ids derived from the keys, the credential signed, the presentation signed by the holder or the last delegate, the chain signed); otherwise they are null, and what the presentation claims is written as claimedHolder / claimedPresenter, only in the form aere-id: + 40 hex, or not at all. Every field copied from a request is bounded (a name, an id of fixed length, a digest), and an entry is at most 16 KB; an entry of an anonymous refusal measured 831 bytes. The proof-kinds builder refuses any value outside the kind's vocabulary. The value never enters the log, nor a digest of it (a digest of a guessable value does not hide it). verify-log, attest-head, notarize-head and the Merkle-tree commands of the sidecar work on this log unchanged, and each entry is VALID for the AIP-23 reference verifier.

Running

export AERE_SECRETS_AUDIENCE=<the name presentations are made for, e.g. https://secrets.example>
export AERE_SECRETS_ADMIN_POLICY=<path of a JSON compliance policy: id, trustedIssuers, require>
export AERE_KMS_ROOT_KEY=<64 hex characters>                  # KMS in this process (or AERE_KMS_URL + AERE_KMS_TOKEN)
node server.mjs
variable default meaning
AERE_SECRETS_AUDIENCE none (required) the audience; 1..256 printable characters without spaces
AERE_SECRETS_ADMIN_POLICY none (required) a JSON compliance policy; a service with no admin policy cannot create a secret
AERE_SECRETS_DATA_DIR ./data next to server.mjs service.json, service.lock, secrets/<name>.json, the audit log, the in-process KMS data
AERE_SECRETS_TRUST_DIR ./trust next to server.mjs status-lists/*.json and revocations/*.json, read at every request
AERE_SECRETS_AUDIT_LOG <data>/aere-audit.log the sidecar log
AERE_KMS_ROOT_KEY / AERE_KMS_DATA_DIR none / <data>/kms the KMS in this process (exactly one of this and AERE_KMS_URL)
AERE_KMS_URL / AERE_KMS_TOKEN none the pq-kms server over HTTP, with its bearer token
AERE_SECRETS_KMS_KEY secrets the KMS encrypt key that wraps the data keys; created if missing
AERE_SECRETS_HOST, AERE_SECRETS_PORT 127.0.0.1, 8430 listen address and port (0 picks a free one)
AERE_SECRETS_MAX_BODY 262144 request body limit, 65536 to 16777216 (a presentation with hybrid signatures is tens of KB)
AERE_SECRETS_NONCE_TTL 120 nonce lifetime in seconds, 5 to 3600
AERE_SECRETS_NONCE_PER_ORIGIN 32 nonces outstanding per address of origin, 1 to 10000 (429 NONCE_ORIGIN_FULL above)
AERE_SECRETS_STALE_LOCK refuse take: take over the service.lock of a process that no longer runs on this host (said on stderr)

No value from the environment, no secret value, no nonce and no key is ever printed. The server speaks plain HTTP on the loopback address by default; put a TLS-terminating proxy (the pq-gateway, for instance) in front of it before exposing it.

HTTP API

Every operation is a POST with a JSON body that carries the presentation, since a presentation does not fit in a header. The whole body reaches the service and enters the request digest. Errors are { error, rule, message }, plus identityRules only when the presentation itself is not valid for this service.

method and path body response
GET /v1/health { ok, audience }
POST /v1/nonce { nonce, audience, expiresAt }
POST /v1/secrets/:name/put { presentation, value (base64), metadata?, grants? } { name, version, current, createdAt }; grants only when the secret is created
POST /v1/secrets/:name/get { presentation, version? } { name, version, current, value (base64), metadata, createdAt }
POST /v1/secrets/:name/metadata { presentation } versions with their state, grants (the whole access list); no value, no sealed material
POST /v1/secrets/:name/rotate { presentation, bytes?: 16..64, encoding?: base64url|hex, metadata? } { name, version, current, createdAt } (a value born here; read it with get)
POST /v1/secrets/:name/revoke-version { presentation, version } { name, version, state: "revoked", revokedAt } (irreversible)
POST /v1/secrets/:name/grant { presentation, grant } { name, grant, grants }
POST /v1/secrets/:name/revoke { presentation, grantId } { name, revoked, grants }
POST /v1/list { presentation } { secrets: [...] }, the names
GET /v1/audit/head { ok, count, head } (public; verifies the whole log: linear in its size)

Secret names match ^[a-z0-9][a-z0-9_-]{0,63}$ and are not Windows device names; a value is 1 to 65,536 bytes; metadata is at most 16 entries of name to string, number or boolean, and __proto__, constructor, prototype are refused as names. The HTTP status follows the rule: 400 for request-form, secret-name, value-form, grant-form; 401 for presentation-form and nonce; 403 for authorization, version-scope and grant-scope; 404 for secret-exists, version-exists and a grant to revoke that does not exist; 409 for a duplicate grant and for grant-limit; 410 for version-revoked; 429 for NONCE_ORIGIN_FULL; 502 for kms; 500 for storage, internal, AUDIT_WRITE_FAILED and STORAGE_WRITE_FAILED.

Library API

import { openSecrets, kmsClientHttp, requestHash } from './secrete.mjs';
import { openKms } from '../pq-kms/kms.mjs';
import { present } from '../identity/identity.mjs';
const kms = openKms({ dataDir: './data/kms', rootKey: process.env.AERE_KMS_ROOT_KEY });   // or kmsClientHttp({ url, token })
const s = await openSecrets({ dataDir: './data', kms, audience: 'https://secrets.example', adminPolicy, trustDir: './trust' });
const { nonce } = s.nonce();                               // hand it to the caller; over HTTP the server passes { origin }
const body = { version: 2 };                               // what the caller asks for
const request = requestHash({ audience: s.audience, operation: 'get', secret: 'db', body });
const presentation = present({ credential, disclosures, reveal, presenter, audience: s.audience, nonce, request });
const r = await s.get('db', { ...body, presentation });   // { name, version, current, value: Buffer, metadata, createdAt }
await s.put('db', { presentation, value: 'hunter2', metadata: { owner: 'platform' }, grants: [deployers] });   // each with its own presentation
await s.rotate('db', { presentation, bytes: 32 }); await s.revokeVersion('db', { presentation, version: 1 });
await s.grant('db', { presentation, grant }); await s.revoke('db', { presentation, grantId: 'readers' });
await s.metadata('db', { presentation }); await s.list({ presentation }); s.auditHead(); s.close();   // close releases service.lock

Refusals throw SecretsError with code and rule, and identityRules when the presentation is not valid for the service; an authorization refusal after a valid identity carries nothing else (the detail is in the audit entry). openSecrets also takes staleLock (refuse or take; tookStaleLock names the PID taken over) and noncePerOrigin. MODULE exports the sibling modules the service resolved (identity, compliance, proof-kinds, sidecar), so a test judges with the same code the service runs.

Storage

  • data/service.json: the audience, the KMS key name, and the service MAC key wrapped by the KMS (aerekms:v<n>:..., AAD aeresecrets/v1/mac-key\0<audience>). Opening the directory with another audience or key name is refused.
  • data/service.lock: { pid, host, since } of the instance that holds the directory, removed by close().
  • data/secrets/<name>.json: the grants (each with createdBy), current_version, and per version created, state, metadata, created_by, kms_version, dek_wrapped (the data key in the KMS envelope, AAD aeresecrets/v1/dek\0<name>\0<version>) and sealed (base64(iv(12) || AES-256-GCM ciphertext || tag(16)) of the value under that data key, AAD aeresecrets/v1/value\0<name>\0<version>). The whole file carries mac = HMAC-SHA-256(service MAC key, canonical JSON); a grant edited on disk is refused (SECRET_FILE_TAMPERED), reported only to a caller the admin policy admits, and audited. Files are written atomically (temp file, fsync, rename; the temp file removed when the rename fails).
  • The KMS audit log records every datakey and decrypt (key name, version, verdict), never a value; the sidecar log records every operation of this service.

Where the shape comes from

Read on 2026-10-18 (public documentation): HashiCorp Vault KV version 2 (a secret is versioned; soft delete, undelete and destroy; metadata with custom_metadata, max_versions, check-and-set), Vault policies (path-based, deny by default, capabilities per operation) and Vault audit devices (every request and response logged, sensitive strings written as keyed hashes, a request refused when no audit device can write); AWS Secrets Manager (versions identified by staging labels AWSCURRENT, AWSPREVIOUS, AWSPENDING, the value encrypted under a KMS key); Google Secret Manager (a secret holds metadata and versions, IAM per secret, CMEK); Azure Key Vault secrets (versions in the identifier, enabled / nbf / exp attributes, soft delete and purge protection, per-principal permissions); OpenID for Verifiable Presentations 1.0 (transaction_data, above). What is kept here: named secrets with numbered versions and metadata, a current version, irreversible revocation of a version, a per-secret policy that names operations, envelope encryption through a KMS, and an audit entry per request, refusals included, written before the change and before the result and never carrying a value. What is different: the principal is not a token, a role or an IAM identity bound to the cloud, but a post-quantum credential presented for this service and this request, bound to a fresh nonce, with delegation and revocation judged offline from files; and the audit log is a chain of AIP-23 envelopes anyone can verify with the reference verifier and anchor on Aere Network. What is not here: leases and dynamic secrets, check-and-set, rotation hooks that reach into a database, replication, soft delete with restore.

Tests

node test/proba-secrete.mjs                                 # the test suite (AERE_VERIFY_PROOF=<verify-proof.mjs> for the envelope test)
node test/control-negativ-secrete.mjs                       # plants defects in a copy; each must turn named tests red (four workers)
node test/control-negativ-secrete.mjs --autoproba           # decoy plants; the control itself must report each as a failure
node test/control-negativ-secrete.mjs --ancore              # only checks that every planting's anchor appears exactly once

test/proba-secrete.mjs makes every key at run time (an issuer, an admin, a deployer, a reader, a rotator, an auditor, a granter, a phone the deployer delegates to, a stranger who issues himself an admin credential, a revoked holder, a holder whose credential names no status list). Its client signs each presentation for the request it sends (the digest above), as an honest client does; a presentation an attack touches after making it leaves untouched, and one made pentru (for) a stated request and sent elsewhere is a substitution. It measures: the authorized flow; each refusal by name (no presentation; a nonce reused, never issued, or expired; a presentation for another service; one made ten minutes ago; one signed by a stranger's key; claims outside the policy; an operation outside the grant; a revoked credential; a credential whose status cannot be judged, the admin's included once the list is removed; a delegate in and out of its scope, and after a revocation is put in trust/); rotation with the old version readable only under versions: "any"; revocation of a version, the sealed material gone from disk; grant and revoke, duplicates, malformed grants, holders; list; the value searched as a string, base64, base64url and hex in every file under the data directory, in the KMS audit log, in the sidecar log and in the server's stdout and stderr, with a positive control of the scanner; a grant edited on disk refused; every operation an audit entry with the fields above and the signed binding's hash; the chain intact after 100 mixed operations; the envelopes VALID for the AIP-23 reference verifier (and one touched after hashing INVALID); the HTTP server in process, started from the command line and with the pq-kms server as a remote KMS; that every declared rule has its marker. And, since the adversarial review of 2026-10-18: a presentation signed for get db refused for revoke on db, for get on db2, for get db with a field added, and over HTTP on revoke-version and on a put whose value changed on the way (identity rule request); a presentation with no request refused; attenuation (operations, grant/revoke not handed on, versions, issuers, policy rules, maximum age, holders, delegates), revoking only one's own grants, the two self-grant attacks of the review refused; the authorization refusal the same bytes for a secret with three grants, with none and for a name that does not exist, through the library and HTTP, with the detail in the log; verified and claimed ids in the log, an anonymous entry under 2 KB; 8 concurrent puts with the remote KMS giving versions 2 to 9, all readable, and a grant concurrent with a rotate both kept; the directory lock (same process, another process, a stale lock refused and taken over only when asked); a binding nested 30,000 times and a body of 25,000 values refused and audited, through the library and HTTP; Windows device names and reserved metadata names; a 5,000-character version not reflected; the id admin reserved; the cap of 32 grants, and a get through the 32nd grant reading the presentation's signature three times (the size check, the identity, one pair of issuers and age for the 33 candidates); 32 nonces per address then 429; the entry written before the file and a failed entry after a write that fails; delegates: false; the vocabulary of the envelope kind.

test/control-negativ-secrete.mjs copies the service, the KMS, the identity verifier, the compliance checker, proof-kinds and the sidecar into temporary trees with the same layout, plants one defect at a time and requires the named tests to fail for the named reason, reading the suite's JSON output, not its exit code, with three states (red as required, green although planted, broken); the originals must be byte-identical afterwards. The 43 plantings: the 17 of the first version (the nonce not consumed or not judged; the service's audience replaced by the presentation's; delegation revocations not read; a grant allowed to waive the status; grant operations not checked; the policy verdict ignored; a revoked version not refused; old versions read without versions: "any"; the server printing the value; the value stored in clear; the file written before the audit entry; in the identity verifier the presenter and the freshness checks removed; in the compliance checker the status not required; refusals not logged; the presentation hash not the signed binding's) and 26 for the review: the request not compared by the identity verifier, not given by the service, its digest not covering the body; attenuation off, grant/revoke handed on, other holders' grants revoked; grant names in the refusal message or in the error; claimed ids unfiltered, claimed ids written as verified; the mutex not awaited; no directory lock; a stale lock taken silently; no size check; Windows names and reserved metadata keys accepted; the version reflected; the id admin allowed; no cap at creation, no cap at grant; no verification cache; no per-address nonce cap, the server not passing the address; no failed entry; delegates: false ignored; the kind's vocabulary not judged.

Measured on 2026-10-18 (UTC), Windows 11, Node.js 24.14.1, OpenSSL 3.5.5, after the repairs of the adversarial review: 43/43 tests pass (about 7 s), with the reference verifier of the development repository and with the served one; the negative control catches 43/43 plantings on the named tests for the named reasons, the originals byte-identical afterwards; its self-test gives 5/5 decoys their failure verdict. The nine attack scripts of the review, run again with their harness made an honest client (it signs each presentation for the request it sends; the attacks that state an intent sign for it), leave 3 of the 17 attacks that succeeded before still succeeding, all three declared above (an envelope made up by hand is VALID; an entry appended or the last ones removed between two attested heads); the same adapted harness run on the code before the repairs reproduces all 17, plus the two it measures that the original did not (the order of the entry and the write, and the per-address nonce cap). Before the repairs: 28/28 tests, 17/17 plantings, 5/5 decoys. Not measured: Linux or macOS (file modes 0o600/0o700 are not applied on Windows), throughput and latency under load, two processes racing for the same stale lock, a crash between the audit entry and the write (the order is measured, the crash is not), side channels other than the timing above, a physical HSM under the KMS, a real deployment behind a TLS proxy.