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 (
runtimeenvelopes 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-accessenvelope made up by hand isVALIDfor the reference verifier (which judges form and hash, not that the access happened), andbuildProofrefuses only values outside the kind's vocabulary. The weight is in the log: its head, signed and notarized outside the host (attest-head,notarize-headof the sidecar), fixes everything before it. Between two attested heads, whoever holds the host can append entries or remove the last ones (agetthat 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/headis 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).metadatashows the whole access list: a holder with themetadataoperation sees every grant of the secret, with its trusted issuers, its claim rules, itsholdersand 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_FULLabove). 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 (raiseAERE_SECRETS_NONCE_PER_ORIGINor 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-versiondeletes 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 (norewraphere yet); raising the KMS key'smin_decryption_versionmakes 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
- 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). Otherwiserequest-formorsecret-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). - 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. - Presentation form. The body carries a
presentationwithbinding.nonce. Otherwisepresentation-form, audited. - The request digest (
requestHash, below) is computed from the request received; a body that JSON cannot carry (a value given through the library) isrequest-form, audited. - 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. Otherwisenonce. - Identity.
verifyPresentationof 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 oftrust/, 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),presenterandpresentation-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. Otherwiseauthorization, withidentityRulesnaming them (they say nothing about the secret: the identity is judged before any grant). - 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
checkComplianceof 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: falseis refused by name at write time), and every claim rule must hold on the claims shown. A grant withholdersadmits only those holders and their delegates; withdelegates: false, the holder alone. The first compliant grant authorizes. None is one answer for every secret:UNAUTHORIZED, ruleauthorization, 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). - 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 saysversions: "any"), whether the version was revoked (version-revoked), and so on. A message names a version only when it is an integer. - The audit entry, then the change, then the result. For an operation that changes the secret, the
secret-accessenvelope 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 verdictfailedand the samerequestHashfollows 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>:..., AADaeresecrets/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 byclose().data/secrets/<name>.json: the grants (each withcreatedBy),current_version, and per versioncreated,state,metadata,created_by,kms_version,dek_wrapped(the data key in the KMS envelope, AADaeresecrets/v1/dek\0<name>\0<version>) andsealed(base64(iv(12) || AES-256-GCM ciphertext || tag(16))of the value under that data key, AADaeresecrets/v1/value\0<name>\0<version>). The whole file carriesmac = 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
datakeyanddecrypt(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.