358 lines
33 KiB
Markdown
358 lines
33 KiB
Markdown
# Aere PQ Secrets
|
|
|
|
A secrets service on top of [Aere PQ KMS](../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](../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](../proof-kinds/)) appended
|
|
to the audit log of the [verification layer](../verify-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:
|
|
|
|
```json
|
|
{ "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
|
|
|
|
```json
|
|
{ "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
|
|
|
|
```sh
|
|
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
|
|
|
|
```js
|
|
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
|
|
|
|
```sh
|
|
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 `put`s 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.
|