openvibers/openvibe.shared Commits

4.110.0: sealed secrets, write-only secret routes and owner settings for openvibe-shared/config (#288)

OP OpenVibers committed 8 days ago · b4450e246b · 10 files, +1383 −98

* 4.110.0: sealed secrets, write-only secret routes and owner settings for openvibe-shared/config

* D11 S2 review: wrap the namespace fingerprint key under the KEK, keep markers stable

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 8235b06..a7a2e4b 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,49 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
++## 4.110.0 — 2026-11-04
++
++- **`openvibe-shared/config`: sealed secrets, write-only secret routes, env pins and the owner settings'
++ schema (D11 S2).** Every new option is opt-in: without a KEK the store stores, reads and redacts
++ exactly as before and reports `sealed: false`.
++ - **Sealed secrets.** A secret-class value is stored as `v1:<kek_id>:<fingerprint>:<hint>:<bind>:…`:
++ a random 256-bit data key seals the value with AES-256-GCM, the KEK seals that data key, and both
++ use the AAD `service|namespace|key|bind`. `values_json` never holds the plaintext again; `get()`
++ opens the envelope for the service, and a summary, snapshot, history or rollback shows only its
++ marker. The KEKs come from `opts.keks` (`{ current, keys }`) or `OV_CONFIG_KEKS` /
++ `OV_CONFIG_KEK_CURRENT`; a value that is not base64 of exactly 32 bytes refuses startup with an
++ error naming the variable, never the key. Only `node:crypto` is used. The namespace fingerprint
++ key is plain only when no KEK is configured; with one, `config_keys` holds that same key wrapped
++ under the current KEK (AES-256-GCM, AAD `openvibe-config-fingerprint|<service>|<namespace>`), so
++ a database dump has no key that reproduces a fingerprint. It is rewrapped on boot and never
++ changes, so markers and checksums are stable across a KEK rotation and when a KEK is first
++ enabled; `reseal()` recomputes each envelope's fingerprint with it.
++ - **`x-bind`.** A secret property can name another key (`x-bind: "base_url"`); its value joins the
++ AAD, so changing it locks the secret until it is entered again. A copied envelope fails on another
++ key or namespace.
++ - **`reseal()`.** Plaintext secrets and envelopes under an older KEK move to the current KEK,
++ revision by revision, preserving fingerprints and never resurrecting a changed bind. It reports
++ `{ resealed, current, plaintext, locked, errors }` and is a 409 `config.no_kek` without a KEK.
++ - **Write-only routes.** `PUT <base>/:namespace/secrets/:key { value, reason }` and `DELETE …` answer
++ a marker (`fingerprint`, four-character `hint`, `set_at`, `set_by`, `bound_to`), never the value.
++ A normal `POST` may no longer write or unset a secret-class key (422 `config.secret_write_only`); a
++ marker round trip is still allowed.
++ - **Env pins and the summary.** `x-env: "NAME"` makes the env value effective and read-only
++ (`summary().pins`, 409 `config.pinned_in_env`). `summary()` now carries `sealed`, `kek`, `pins`,
++ `secrets` and `schema`; a secret property's `default`, `examples`, `enum` and `const` are removed
++ from the schema copy. The annotations `title`, `description`, `x-group`, `x-env`, `x-danger`,
++ `x-bind` and `x-unit` are checked at creation.
++ - **Hooks.** `onChange({ namespace, keys, actor, revision })` runs after every successful activation
++ (a throwing hook is logged, never fatal); `requireFresh(req, { namespace, keys })` on `adminRoutes`
++ runs before secret writes and `x-danger` keys and answers 403 `config.fresh_required` when false.
++ - `test/config-secrets.test.js` covers the envelopes and their AAD, x-bind and copied envelopes,
++ write-only routes, pins, the summary schema, the hooks, reseal and rotation, that a dump of
++ `config_keys` and the snapshots has no key that reproduces a fingerprint, that the fingerprint is
++ stable across a k1→k2 rotation and a KEK first enabled, that no-KEK markers are unchanged, and
++ that no value reaches a summary, snapshot, history, error, log or `values_json`; the existing test
++ still proves the no-KEK call sites and redaction are unchanged. `npm test`: 129/130 files passed,
++ 1 skipped (floating-promises, acorn not installed).
++
## 4.109.2 — 2026-11-03
- **A select inside a modal dialog or a fullscreen player opens over it.** The list (menu or phone sheet) used to be
ddiff --git a/README.md b/README.md
iindex dcd291f..0a9e076 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.109.2 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.110.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
@@@ -268,6 +268,8 @@ const settings = await config.createConfigStore({
defaults, // revision 1 of a new namespace, and under every revision
legacy: async () => config.fromRows(await db.all('SELECT * FROM site_settings')), // revision 1 instead
onActivate: async (values, previous, { restoring }) => { /* apply it; throw to refuse */ },
++ keks: { current: 'k2', keys: { k1, k2 } }, // 32-byte Buffers; or OV_CONFIG_KEKS / OV_CONFIG_KEK_CURRENT
++ onChange: async ({ namespace, keys, actor, revision }) => { /* the operator alert */ },
keep: 50, log: console, now: () => new Date(),
});
settings.get(); // defaults overlaid with the active values: frozen, in memory
@@@ -280,6 +282,11 @@ await settings.rollback({ actor, reason, to }); // a new revisio
await settings.lastKnownGood(); await settings.history({ limit, before }); await settings.snapshot(revision); await settings.summary();
await settings.import(legacy); // revision 1 from a legacy source when the namespace has none
await settings.reload(); // another process changed it: re-read the active revision
++await settings.setSecret(key, value, { actor, reason }); // the write-only path; returns the marker
++await settings.deleteSecret(key, { actor, reason }); // removes it as a new revision
++await settings.secretMarker(key); // fingerprint, hint, set_at, set_by, bound_to (or null)
++await settings.reseal(); // plaintext and old-KEK rows to the current KEK
++settings.sealed; settings.kek; // false / null without a KEK
```
- **Values** are a complete snapshot; `merge: true` starts from the active values and `unset` removes
@@@ -295,14 +302,54 @@ await settings.reload(); // another process changed it: re-read
on the last-known-good.
- **Last-known-good** is the newest revision that activated successfully. Pruning keeps the newest
`keep` revisions and never the active one or the last-known-good.
--- **Secrets** stay in the row so they can be activated again, but leave only redacted (history,
-- summary, routes, the return values) and never reach the log or a stored error. A redacted value is
-- `{ redacted: true, fingerprint }`: HMAC-SHA256 of its canonical JSON under a random 32-byte key made
-- for the namespace on first use and kept in `config_keys`. The key never leaves the database and is
-- never logged, so equal fingerprints mean an unchanged value while nothing can be guessed offline. A
++- **Secrets** leave only redacted (history, summary, routes, the return values) and never reach the
++ log or a stored error. A redacted value is `{ redacted: true, fingerprint }`: HMAC-SHA256 of its
++ canonical JSON under a random 32-byte key made for the namespace on first use and kept in
++ `config_keys`. The key is never logged; with a KEK it is stored there wrapped, so a database dump
++ has the fingerprint but no key that reproduces it. Equal fingerprints mean an unchanged value. A
snapshot's `checksum` is the sha256 of its values as shown. Losing the key row only means a new key:
older markers stop round-tripping. A key classified secret later is redacted in older revisions too.
Errors name the rule, never the value.
++- **Sealed secrets (D11).** With a KEK, a secret-class value never enters `values_json`: it is stored
++ as a versioned envelope (`v1:<kek_id>:<fingerprint>:<hint>:<bind>:…`) that a random 256-bit
++ data key sealed with AES-256-GCM, itself sealed by the current KEK (AES-256-GCM), with the AAD
++ `service|namespace|key|bind` on both. `get()` opens the envelope for the service (the plaintext
++ lives in memory exactly as before); a summary, snapshot, history entry or rollback shows a marker.
++ - The KEKs come from `opts.keks` (`{ current, keys: { k1: Buffer } }`) or, by default, from
++ `OV_CONFIG_KEKS=k1:<base64 32 bytes>[,k2:…]` and `OV_CONFIG_KEK_CURRENT=k2`. A value that does not
++ decode to exactly 32 bytes refuses startup with an error that names the variable and the KEK id,
++ never a byte of the key. Only `node:crypto` is used.
++ - No KEK means today's behaviour, untouched and unflagged: `sealed: false`, `kek: null`, the value
++ stays in the row. `reseal()` moves plaintext secrets and envelopes under an older KEK to the
++ current KEK revision by revision (it never resurrects one whose bind changed) and reports
++ `{ resealed, current, plaintext, locked, errors }`.
++ - **The fingerprint key.** With no KEK, `config_keys.hmac_key` is today's plain 32-byte namespace
++ key. With a KEK it is the same key sealed under the current KEK there (AES-256-GCM, AAD
++ `openvibe-config-fingerprint|<service>|<namespace>`), never the key itself; the store unwraps it
++ in memory and rewraps it under a new current KEK on boot. The key itself never changes, so markers
++ and checksums do not move across a KEK rotation, or when a KEK is first enabled on an existing
++ namespace. This is the wrapped-key alternative to deriving it from `k1`: a wrapped key does not
++ depend on `k1` surviving a rotation, and enabling a KEK preserves every marker written before it.
++ `reseal()` recomputes each resealed envelope's fingerprint with this stable key.
++ - **Rotation.** Add the new KEK next to the old one and set it current. The first boot rewraps the
++ fingerprint key (the key is unchanged); `reseal()` moves the envelopes to the new KEK and
++ recomputes their fingerprints; then the old KEK can be removed.
++ - A `v1:…` envelope string is reserved for a stored secret: a value that already parses as an
++ envelope is carried as one (that is also why a copied envelope never opens under another key).
++- **Bind, pins and the summary schema.** A secret property's `x-bind: "<key>"` puts that key's value
++ (e.g. a provider's `base_url`) into the AAD: changing it locks the secret (`get()` gives undefined)
++ until the owner enters it again for the new value. `x-env: "NAME"` pins a key to the process
++ environment: the summary shows the env value with `pinned: true`, and a write to it is 409
++ `config.pinned_in_env`. `summary()` now carries `sealed`, `kek`, `pins`, `secrets` (each secret's
++ marker with its four-character `hint`, `set_at`, `set_by`, `bound_to`) and `schema`, the JSON
++ Schema to draw the form from; a secret property's `default`, `examples`, `enum` and `const` are
++ removed from that copy. The annotations are `title`, `description`, `x-group`, `x-env`, `x-danger`,
++ `x-bind` and `x-unit` (`nano_usd`, `ms`, `bps`).
++- **Hooks.** `onChange({ namespace, keys, actor, revision })` runs after every successful activation
++ (never for a proposal or the seed), and a throwing hook is logged and never refuses the revision.
++ `requireFresh(req, { namespace, keys })`, an `adminRoutes` option, runs before a secret write, a
++ rollback that changes a secret or an `x-danger` key, and a danger write; a false answer is 403
++ `config.fresh_required`.
- `config.fromRows(rows, { prefix, type })` reads typed key-value rows (`number`, `boolean`, `json`,
anything else a string), as Live's and Network's `site_settings` and Media's `media_settings` keep them.
@@@ -311,20 +358,24 @@ config.adminRoutes([settings, other], {
requireAdmin, // a middleware or a list; guards every handler
actor: (req) => ({ type: 'user', id: req.user.subject_id }), // default: req.actor, req.subject, req.user.subject(_id)
authorize: (req, { action, namespace, keys }) => action === 'read' || isOwner(req.user) || !keys.some(isSecret),
++ requireFresh: (req, { namespace, keys }) => isFreshSignIn(req), // secrets and x-danger keys; false → 403
basePath: '/api/admin/config',
}).mount(app); // or { router }, the handlers, or handle(req, res, next) on plain http
```
| Route | Answer |
|---|---|
--| `GET /api/admin/config` | `{ namespaces: [{ service, namespace, revision, values, classification, active, last_known_good }] }`, values redacted |
++| `GET /api/admin/config` | `{ namespaces: [{ service, namespace, revision, values, classification, sealed, kek, pins, secrets, schema, active, last_known_good }] }`, values redacted |
| `GET /api/admin/config/:namespace` | one of them |
| `GET /api/admin/config/:namespace/history?limit&before` | `{ namespace, snapshots, next_before }`, newest first |
--| `POST /api/admin/config/:namespace` `{ values, reason, merge?, unset? }` | the new active snapshot; 422 `config.invalid` with `errors[]`, 422 `config.activation_failed` |
++| `POST /api/admin/config/:namespace` `{ values, reason, merge?, unset? }` | the new active snapshot; 422 `config.invalid` with `errors[]`, 422 `config.activation_failed`; a secret-class value or unset is 422 `config.secret_write_only`, an env-pinned key 409 `config.pinned_in_env` |
| `POST /api/admin/config/:namespace/rollback` `{ to?, reason }` | the new active snapshot; 409 `config.no_previous`, `config.not_good` |
++| `PUT /api/admin/config/:namespace/secrets/:key` `{ value, reason }` | the marker `{ redacted, fingerprint, hint, set_at, set_by, bound_to }`; write-only |
++| `DELETE /api/admin/config/:namespace/secrets/:key` `{ reason }` | the empty marker; a new revision without the key |
Errors are `application/problem+json` (`errors.problem@1`). Mount it after a JSON body parser, or
--let it read the body itself.
++let it read the body itself. A normal POST cannot write a secret-class value: the two `/secrets`
++routes are the only write path, and they answer a marker, never the value.
### Releases: `/release.json`, open tabs and the mixed-version test (ADR-016, Track R)
ddiff --git a/STATUS.json b/STATUS.json
iindex c7da86f..7e8cb14 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.109.2",
++ "version": "4.110.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/config-core.js b/config-core.js
iindex 4d225d8..f2f06a3 100644
---- a/config-core.js
++++ b/config-core.js
@@@ -63,6 +63,197 @@ const isMarker = (v) => isPlainObject(v) && v.redacted === true && Object.keys(v
const looksRedacted = (v) => isPlainObject(v) && has(v, 'redacted');
const sameHex = (a, b) => typeof a === 'string' && typeof b === 'string' && a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
++// ── Sealed secrets (D11) ────────────────────────────────────────
++// A secret-class value is stored as v1:<kek_id>:<fingerprint>:<hint>:<bind>:<kek iv>:<wrapped key>:<body iv>:<body tag>:<body>.
++// The wrapped key is a random 256-bit data key sealed by the KEK (ciphertext then its 16-byte GCM tag); the body is the
++// JSON of the value sealed by that data key. Both use AES-256-GCM with AAD service|namespace|key|bind. fingerprint is
++// the namespace's HMAC of the plaintext (so markers stay stable across reseals), hint the last four characters (or
++// empty), and bind the value the secret was sealed to. A bad or foreign envelope never throws out of get(): it is locked.
++
++const KEK_ID = /^[a-z0-9][a-z0-9_-]{0,31}$/;
++const B64_STD = /^[A-Za-z0-9+/]+={0,2}$/;
++const B64_URL = /^[A-Za-z0-9_-]*$/;
++const IV_BYTES = 12;
++const TAG_BYTES = 16;
++const KEY_BYTES = 32;
++
++function b64(buf) { return Buffer.from(buf).toString('base64'); }
++function unb64(s, length) {
++ if (typeof s !== 'string' || !B64_STD.test(s) || s.length % 4 === 1) return null;
++ const b = Buffer.from(s, 'base64');
++ if (length !== undefined && b.length !== length) return null;
++ return b;
++}
++const b64url = (s) => Buffer.from(String(s), 'utf8').toString('base64url');
++function unb64url(s, max = 4096) {
++ if (typeof s !== 'string' || !B64_URL.test(s) || s.length > max * 2) return null;
++ const b = Buffer.from(s, 'base64url');
++ if (b.length > max) return null;
++ return b.toString('utf8');
++}
++const aadOf = ({ service, namespace, key, bind }) => Buffer.from(`${service}|${namespace}|${key}|${bind == null ? '' : String(bind)}`, 'utf8');
++const bindText = (v) => (v == null ? '' : typeof v === 'string' ? v : canonical(v));
++/** The last four characters of a long enough string, never a short one (a five-character secret must not be shown). */
++function hintOf(value) { return typeof value === 'string' && value.length >= 8 ? [...value].slice(-4).join('') : null; }
++
++/** Parse a stored envelope without any key. Null means "not an envelope". */
++function parseEnvelope(v) {
++ if (typeof v !== 'string' || !v.startsWith('v1:')) return null;
++ const parts = v.split(':');
++ if (parts.length !== 10) return null;
++ const [, kekId, fingerprint, hintB64, bindB64, kekIvB64, wrappedB64, bodyIvB64, tagB64, bodyB64] = parts;
++ if (!KEK_ID.test(kekId) || !/^[0-9a-f]{64}$/.test(fingerprint)) return null;
++ const hint = unb64url(hintB64, 64);
++ const bind = unb64url(bindB64, 4096);
++ const kekIv = unb64(kekIvB64, IV_BYTES);
++ const wrapped = unb64(wrappedB64, KEY_BYTES + TAG_BYTES);
++ const bodyIv = unb64(bodyIvB64, IV_BYTES);
++ const tag = unb64(tagB64, TAG_BYTES);
++ const body = unb64(bodyB64);
++ if (hint === null || bind === null || !kekIv || !wrapped || !bodyIv || !tag || !body) return null;
++ return { version: 'v1', kekId, fingerprint, hint, bind, kekIv, wrapped, bodyIv, tag, body };
++}
++const isEnvelope = (v) => parseEnvelope(v) !== null;
++
++/** Seal one secret-class value. `fingerprint` is the namespace HMAC of the plaintext; `kek` is 32 bytes. */
++function sealEnvelope({ service, namespace, key, value, bind = '', fingerprint, hint = null, kekId, kek }) {
++ if (typeof kekId !== 'string' || !KEK_ID.test(kekId)) throw new TypeError('sealEnvelope: kek id must be k1, k2, …');
++ if (!Buffer.isBuffer(kek) || kek.length !== KEY_BYTES) throw new TypeError('sealEnvelope: the KEK must be a 32-byte Buffer');
++ if (typeof fingerprint !== 'string' || !/^[0-9a-f]{64}$/.test(fingerprint)) throw new TypeError('sealEnvelope: fingerprint must be 64 hex characters');
++ const aad = aadOf({ service, namespace, key, bind });
++ const dataKey = crypto.randomBytes(KEY_BYTES);
++ const bodyIv = crypto.randomBytes(IV_BYTES);
++ const body = crypto.createCipheriv('aes-256-gcm', dataKey, bodyIv);
++ body.setAAD(aad);
++ const sealedBody = Buffer.concat([body.update(JSON.stringify(value), 'utf8'), body.final()]);
++ const tag = body.getAuthTag();
++ const kekIv = crypto.randomBytes(IV_BYTES);
++ const wrap = crypto.createCipheriv('aes-256-gcm', kek, kekIv);
++ wrap.setAAD(aad);
++ const wrapped = Buffer.concat([wrap.update(dataKey), wrap.final(), wrap.getAuthTag()]);
++ return ['v1', kekId, fingerprint, b64url(hint || ''), b64url(bind || ''), b64(kekIv), b64(wrapped), b64(bodyIv), b64(tag), b64(sealedBody)].join(':');
++}
++
++const secretUnreadable = (why) => Object.assign(new Error(`secret does not open (${why})`), { code: 'config.secret_unreadable' });
++
++// ── The namespace fingerprint key, sealed under the KEK (D11 S2 review) ─────────────────────────
++// With a KEK, config_keys.hmac_key is not the key: it is the 32-byte key wrapped with the current KEK
++// (AES-256-GCM, AAD openvibe-config-fingerprint|<service>|<namespace>) in this small versioned form.
++// A database dump then has the fingerprints but no key that reproduces them. The same key is kept
++// across KEK rotations (rewrapped on boot), so markers and checksums never move.
++const FP_WRAP_PREFIX = 'ovfp.v1.';
++const fpAad = ({ service, namespace }) => Buffer.from(`openvibe-config-fingerprint|${service}|${namespace}`, 'utf8');
++const isWrappedFingerprintKey = (stored) => stored != null && Buffer.from(stored).toString('utf8').startsWith(FP_WRAP_PREFIX);
++
++function sealFingerprintKey(key, { service, namespace, kekId, kek }) {
++ if (!Buffer.isBuffer(key) || key.length !== KEY_BYTES) throw new TypeError('sealFingerprintKey: the fingerprint key must be a 32-byte Buffer');
++ if (typeof kekId !== 'string' || !KEK_ID.test(kekId)) throw new TypeError('sealFingerprintKey: kek id must be k1, k2, …');
++ if (!Buffer.isBuffer(kek) || kek.length !== KEY_BYTES) throw new TypeError('sealFingerprintKey: the KEK must be a 32-byte Buffer');
++ const iv = crypto.randomBytes(IV_BYTES);
++ const cipher = crypto.createCipheriv('aes-256-gcm', kek, iv);
++ cipher.setAAD(fpAad({ service, namespace }));
++ const body = Buffer.concat([cipher.update(key), cipher.final()]);
++ const tag = cipher.getAuthTag();
++ return Buffer.from(`${FP_WRAP_PREFIX}${kekId}:${b64(iv)}:${b64(tag)}:${b64(body)}`, 'utf8');
++}
++
++/** Open a wrapped fingerprint key: { key, kekId }. Null when the value is a plain 32-byte key. */
++function openFingerprintKey(stored, { service, namespace, keys }) {
++ const text = Buffer.from(stored).toString('utf8');
++ if (!text.startsWith(FP_WRAP_PREFIX)) return null;
++ const fail = (why) => Object.assign(new Error(`createConfigStore: the fingerprint key of ${namespace} in config_keys ${why}`), { code: 'config.fingerprint_key_unreadable' });
++ const parts = text.slice(FP_WRAP_PREFIX.length).split(':');
++ if (parts.length !== 4 || !KEK_ID.test(parts[0])) throw fail('is not a valid wrapper');
++ const [kekId, ivB64, tagB64, bodyB64] = parts;
++ const iv = unb64(ivB64, IV_BYTES);
++ const tag = unb64(tagB64, TAG_BYTES);
++ const body = unb64(bodyB64, KEY_BYTES);
++ if (!iv || !tag || !body) throw fail('is not a valid wrapper');
++ const kek = keys && typeof keys.get === 'function' ? keys.get(kekId) : keys && keys[kekId];
++ if (!kek) throw fail(`is sealed under the KEK "${kekId}", which is not configured`);
++ try {
++ const decipher = crypto.createDecipheriv('aes-256-gcm', kek, iv);
++ decipher.setAAD(fpAad({ service, namespace }));
++ decipher.setAuthTag(tag);
++ const key = Buffer.concat([decipher.update(body), decipher.final()]);
++ if (key.length !== KEY_BYTES) throw new Error('wrong length');
++ return { key, kekId };
++ } catch {
++ throw fail(`does not open with the KEK "${kekId}"`);
++ }
++}
++
++/** Open one envelope: { value, meta }. Throws a coded error when the KEK, bind or tag is wrong. */
++function openEnvelope(envelope, { service, namespace, key, bind = '', keys }) {
++ const p = parseEnvelope(envelope);
++ if (!p) throw secretUnreadable('not an envelope');
++ const kek = keys && typeof keys.get === 'function' ? keys.get(p.kekId) : keys && keys[p.kekId];
++ if (!kek) throw secretUnreadable(`no KEK ${p.kekId}`);
++ const aad = aadOf({ service, namespace, key, bind });
++ try {
++ const wrapped = p.wrapped;
++ const unwrap = crypto.createDecipheriv('aes-256-gcm', kek, p.kekIv);
++ unwrap.setAAD(aad);
++ unwrap.setAuthTag(wrapped.subarray(KEY_BYTES));
++ const dataKey = Buffer.concat([unwrap.update(wrapped.subarray(0, KEY_BYTES)), unwrap.final()]);
++ const open = crypto.createDecipheriv('aes-256-gcm', dataKey, p.bodyIv);
++ open.setAAD(aad);
++ open.setAuthTag(p.tag);
++ const plain = Buffer.concat([open.update(p.body), open.final()]).toString('utf8');
++ return { value: JSON.parse(plain), meta: { ...p } };
++ } catch (err) {
++ throw secretUnreadable(err && err.code ? err.code : 'wrong KEK, bind or tag');
++ }
++}
++
++/**
++ * The KEKs of a store: opts.keks ({ current, keys: { k1: Buffer } }) when given (false/null disables sealing), else
++ * OV_CONFIG_KEKS and OV_CONFIG_KEK_CURRENT. Every value must be base64 of exactly 32 bytes. Errors name the variable
++ * or the option and the key id, never a byte of the key.
++ */
++function keksOf(opts = {}, env = process.env) {
++ if (opts.keks === false || opts.keks === null) return null;
++ if (opts.keks !== undefined) {
++ if (!isPlainObject(opts.keks)) throw new TypeError('createConfigStore: opts.keks must be { current, keys }');
++ const keys = new Map();
++ const raw = opts.keks.keys;
++ if (!isPlainObject(raw) || !Object.keys(raw).length) throw new TypeError('createConfigStore: opts.keks.keys must name at least one KEK');
++ for (const [id, k] of Object.entries(raw)) {
++ if (!KEK_ID.test(id)) throw new TypeError(`createConfigStore: opts.keks.keys has an invalid KEK id "${String(id).slice(0, 40)}"`);
++ if (!Buffer.isBuffer(k) || k.length !== KEY_BYTES) throw new TypeError(`createConfigStore: opts.keks.keys.${id} must be a 32-byte Buffer`);
++ keys.set(id, Buffer.from(k));
++ }
++ if (typeof opts.keks.current !== 'string' || !KEK_ID.test(opts.keks.current)) throw new TypeError('createConfigStore: opts.keks.current must name a KEK id (k1, k2, …)');
++ if (!keys.has(opts.keks.current)) throw new TypeError(`createConfigStore: opts.keks.current names the unknown KEK "${opts.keks.current}"`);
++ return { current: opts.keks.current, keys };
++ }
++ const list = env && env.OV_CONFIG_KEKS;
++ const current = env && env.OV_CONFIG_KEK_CURRENT;
++ if (list == null || list === '') {
++ if (current) throw new TypeError('createConfigStore: OV_CONFIG_KEK_CURRENT is set but OV_CONFIG_KEKS is empty');
++ return null;
++ }
++ const keys = new Map();
++ for (const part of String(list).split(',')) {
++ const s = part.trim();
++ if (!s) continue;
++ const at = s.indexOf(':');
++ if (at < 1) throw new TypeError('createConfigStore: every OV_CONFIG_KEKS entry must be <id>:<base64 32-byte key>');
++ const id = s.slice(0, at).trim();
++ const value = s.slice(at + 1).trim();
++ if (!KEK_ID.test(id)) throw new TypeError(`createConfigStore: OV_CONFIG_KEKS has an invalid KEK id "${id.slice(0, 40)}"`);
++ if (keys.has(id)) throw new TypeError(`createConfigStore: OV_CONFIG_KEKS names the KEK "${id}" twice`);
++ const k = unb64(value, KEY_BYTES);
++ if (!k) throw new TypeError(`createConfigStore: OV_CONFIG_KEKS entry "${id}" must be base64 of exactly 32 bytes`);
++ keys.set(id, k);
++ }
++ if (!keys.size) throw new TypeError('createConfigStore: OV_CONFIG_KEKS names no KEK');
++ if (!current) throw new TypeError('createConfigStore: OV_CONFIG_KEKS is set, so OV_CONFIG_KEK_CURRENT must name the current KEK id');
++ if (!KEK_ID.test(current)) throw new TypeError(`createConfigStore: OV_CONFIG_KEK_CURRENT is not a KEK id ("${String(current).slice(0, 40)}")`);
++ if (!keys.has(current)) throw new TypeError(`createConfigStore: OV_CONFIG_KEK_CURRENT names the unknown KEK "${current}"`);
++ return { current, keys };
++}
++
// ── A small JSON Schema subset ──────────────────────────────────
// type, enum, const, properties, required, additionalProperties, min/maxProperties, items,
// min/maxItems, uniqueItems, min/maxLength, pattern, minimum, maximum, exclusiveMinimum/Maximum,
@@@ -74,8 +265,21 @@ const KEYWORDS = new Set(['type', 'enum', 'const', 'properties', 'required', 'ad
'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf']);
const ANNOTATIONS = new Set(['$schema', '$id', '$comment', 'title', 'description', 'default', 'examples', 'deprecated', 'readOnly', 'writeOnly', 'format']);
const TYPES = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'];
++const UNITS = new Set(['nano_usd', 'ms', 'bps']);
++const ENV_NAME = /^[A-Z][A-Z0-9_]{0,127}$/;
const patterns = new WeakMap();
++/** The owner-settings annotations of the summary (D11): x-env, x-danger, x-bind and x-unit are checked. */
++function checkAnnotations(s, at) {
++ if (s['x-env'] !== undefined && (typeof s['x-env'] !== 'string' || !ENV_NAME.test(s['x-env']))) throw new TypeError(`config schema ${at}: x-env must be an env name (A-Z, 0-9, _)`);
++ if (s['x-bind'] !== undefined && (typeof s['x-bind'] !== 'string' || !s['x-bind'])) throw new TypeError(`config schema ${at}: x-bind must name another key`);
++ if (s['x-danger'] !== undefined && typeof s['x-danger'] !== 'boolean') throw new TypeError(`config schema ${at}: x-danger must be true or false`);
++ if (s['x-unit'] !== undefined && !UNITS.has(s['x-unit'])) throw new TypeError(`config schema ${at}: x-unit must be one of ${[...UNITS].join(', ')}`);
++ if (s['x-group'] !== undefined && typeof s['x-group'] !== 'string') throw new TypeError(`config schema ${at}: x-group must be a string`);
++ if (s.title !== undefined && typeof s.title !== 'string') throw new TypeError(`config schema ${at}: title must be a string`);
++ if (s.description !== undefined && typeof s.description !== 'string') throw new TypeError(`config schema ${at}: description must be a string`);
++}
++
function compileSchema(s, at = '#') {
if (typeof s === 'boolean') return;
if (!isPlainObject(s)) throw new TypeError(`config schema ${at}: must be an object or a boolean`);
@@@ -83,12 +287,47 @@ function compileSchema(s, at = '#') {
if (!KEYWORDS.has(k) && !ANNOTATIONS.has(k) && !k.startsWith('x-')) throw new TypeError(`config schema ${at}: unsupported keyword "${k}"`);
}
if (s.type !== undefined && ![].concat(s.type).every((t) => TYPES.includes(t))) throw new TypeError(`config schema ${at}: unknown type`);
++ checkAnnotations(s, at);
++ if (at === '#' && isPlainObject(s.properties)) {
++ for (const [k, sub] of Object.entries(s.properties)) {
++ if (!isPlainObject(sub) || typeof sub['x-bind'] !== 'string') continue;
++ if (sub['x-bind'] === k) throw new TypeError(`config schema ${at}/properties/${k}: x-bind cannot name itself`);
++ if (!has(s.properties, sub['x-bind'])) throw new TypeError(`config schema ${at}/properties/${k}: x-bind names an unknown key "${sub['x-bind']}"`);
++ if (isPlainObject(s.properties[sub['x-bind']]) && has(s.properties[sub['x-bind']], 'x-bind')) throw new TypeError(`config schema ${at}/properties/${k}: x-bind cannot chain to another bound key`);
++ }
++ }
if (s.pattern !== undefined) patterns.set(s, new RegExp(s.pattern, 'u'));
if (s.properties) for (const [k, sub] of Object.entries(s.properties)) compileSchema(sub, `${at}/properties/${k}`);
if (s.additionalProperties !== undefined) compileSchema(s.additionalProperties, `${at}/additionalProperties`);
if (s.items !== undefined) compileSchema(s.items, `${at}/items`);
}
++/** The declared env pins and binds of a schema's top-level properties. */
++function schemaAnnotations(schema) {
++ const out = { env: {}, bind: {} };
++ if (isPlainObject(schema) && isPlainObject(schema.properties)) {
++ for (const [k, p] of Object.entries(schema.properties)) {
++ if (!isPlainObject(p)) continue;
++ if (typeof p['x-env'] === 'string') out.env[k] = p['x-env'];
++ if (typeof p['x-bind'] === 'string') out.bind[k] = p['x-bind'];
++ }
++ }
++ return out;
++}
++
++/** A JSON Schema for the summary: secret-class properties lose default/examples, which could hold a secret. */
++function summarySchema(schema, isSecret) {
++ if (schema == null) return null;
++ const out = clone(schema);
++ if (isPlainObject(out.properties)) {
++ for (const [k, p] of Object.entries(out.properties)) {
++ if (!isPlainObject(p) || !isSecret(k)) continue;
++ delete p.default; delete p.examples; delete p.enum; delete p.const;
++ }
++ }
++ return deepFreeze(out);
++}
++
function typeOf(v) {
if (v === null) return 'null';
if (Array.isArray(v)) return 'array';
@@@ -100,15 +339,16 @@ function isType(v, t) {
return typeOf(v) === t;
}
--/** Messages name the rule, never the value (a value may be a secret). */
--function checkSchema(s, v, path, errors) {
++/** Messages name the rule, never the value (a value may be a secret). `skip` holds paths whose value cannot be read. */
++function checkSchema(s, v, path, errors, skip = null) {
++ if (skip && skip.has(path)) return;
if (s === true || s === undefined) return;
if (s === false) { errors.push({ path, message: 'is not allowed' }); return; }
if (s.type !== undefined) {
const types = [].concat(s.type);
if (!types.some((t) => isType(v, t))) { errors.push({ path, message: `must be ${types.join(' or ')}` }); return; }
}
-- if (s.enum !== undefined && !s.enum.some((e) => same(e, v))) errors.push({ path, message: `must be one of ${s.enum.map((e) => JSON.stringify(e)).join(', ')}` });
++ if (s.enum !== undefined && !s.enum.some((e) => same(e, v))) errors.push({ path, message: 'must be one of the allowed values' });
if (s.const !== undefined && !same(s.const, v)) errors.push({ path, message: 'must equal the constant' });
if (typeof v === 'number') {
if (s.minimum !== undefined && v < s.minimum) errors.push({ path, message: `must be >= ${s.minimum}` });
@@@ -127,17 +367,17 @@ function checkSchema(s, v, path, errors) {
if (s.minItems !== undefined && v.length < s.minItems) errors.push({ path, message: `must have at least ${s.minItems} items` });
if (s.maxItems !== undefined && v.length > s.maxItems) errors.push({ path, message: `must have at most ${s.maxItems} items` });
if (s.uniqueItems && new Set(v.map(canonical)).size !== v.length) errors.push({ path, message: 'must not repeat items' });
-- if (s.items !== undefined) v.forEach((x, i) => checkSchema(s.items, x, `${path}/${i}`, errors));
++ if (s.items !== undefined) v.forEach((x, i) => checkSchema(s.items, x, `${path}/${i}`, errors, skip));
}
if (isPlainObject(v)) {
const keys = Object.keys(v);
-- for (const r of s.required || []) if (!has(v, r)) errors.push({ path: `${path}${pointer(r)}`, message: 'is required' });
++ for (const r of s.required || []) if (!has(v, r) && !(skip && skip.has(`${path}${pointer(r)}`))) errors.push({ path: `${path}${pointer(r)}`, message: 'is required' });
if (s.minProperties !== undefined && keys.length < s.minProperties) errors.push({ path, message: `must have at least ${s.minProperties} keys` });
if (s.maxProperties !== undefined && keys.length > s.maxProperties) errors.push({ path, message: `must have at most ${s.maxProperties} keys` });
for (const k of keys) {
-- if (s.properties && has(s.properties, k)) checkSchema(s.properties[k], v[k], `${path}${pointer(k)}`, errors);
++ if (s.properties && has(s.properties, k)) checkSchema(s.properties[k], v[k], `${path}${pointer(k)}`, errors, skip);
else if (s.additionalProperties === false) errors.push({ path: `${path}${pointer(k)}`, message: 'is not a known key' });
-- else if (s.additionalProperties !== undefined) checkSchema(s.additionalProperties, v[k], `${path}${pointer(k)}`, errors);
++ else if (s.additionalProperties !== undefined) checkSchema(s.additionalProperties, v[k], `${path}${pointer(k)}`, errors, skip);
}
}
}
@@@ -212,4 +452,5 @@ function fromRows(rows, { prefix = '', type = null } = {}) {
return out;
}
--module.exports = { CLASSES, SERVICE_RE, NAMESPACE_RE, validKey, REASON_MAX, ERROR_MAX, ConfigError, canonical, sha256, isPlainObject, pointer, clone, same, has, deepFreeze, jsonErrors, isMarker, looksRedacted, sameHex, compileSchema, checkSchema, validateResult, subjectRef, scrub, errText, summarize, makeLog, fromRows };
++module.exports = { CLASSES, SERVICE_RE, NAMESPACE_RE, validKey, REASON_MAX, ERROR_MAX, ConfigError, canonical, sha256, isPlainObject, pointer, clone, same, has, deepFreeze, jsonErrors, isMarker, looksRedacted, sameHex, compileSchema, checkSchema, validateResult, subjectRef, scrub, errText, summarize, makeLog, fromRows,
++ KEK_ID, KEY_BYTES, IV_BYTES, TAG_BYTES, isEnvelope, parseEnvelope, sealEnvelope, openEnvelope, isWrappedFingerprintKey, sealFingerprintKey, openFingerprintKey, keksOf, hintOf, bindText, schemaAnnotations, summarySchema };
ddiff --git a/config-pg.js b/config-pg.js
iindex bc19394..684ca31 100644
---- a/config-pg.js
++++ b/config-pg.js
@@@ -4,8 +4,15 @@
// implementation when it is handed an openvibe-sdk/db handle, and then returns a promise of the store (it has read or
// seeded its active revision when it resolves). The tables come from
// the service's migrations: configSchema() is their DDL.
++//
++// Secret-class values are sealed (D11) when the store has a KEK: a random AES-256-GCM data key seals the value, a
++// versioned KEK seals that data key, and values_json only ever holds the envelope. get() opens it for the service;
++// summaries, snapshots, history and errors carry a keyed fingerprint marker only. x-bind adds the value of another
++// key to the AAD, so changing that value locks the secret until it is entered again. Without a KEK nothing is sealed
++// and the store behaves exactly as before, but reports sealed: false.
const crypto = require('crypto');
--const { CLASSES, SERVICE_RE, NAMESPACE_RE, validKey, REASON_MAX, ERROR_MAX, ConfigError, canonical, sha256, isPlainObject, pointer, clone, same, has, deepFreeze, jsonErrors, isMarker, looksRedacted, sameHex, compileSchema, checkSchema, validateResult, subjectRef, scrub, errText, summarize, makeLog, fromRows } = require('./config-core');
++const { CLASSES, SERVICE_RE, NAMESPACE_RE, validKey, REASON_MAX, ERROR_MAX, ConfigError, canonical, sha256, isPlainObject, pointer, clone, same, has, deepFreeze, jsonErrors, isMarker, looksRedacted, sameHex, compileSchema, checkSchema, validateResult, subjectRef, scrub, errText, summarize, makeLog, fromRows,
++ isEnvelope, parseEnvelope, sealEnvelope, openEnvelope, isWrappedFingerprintKey, sealFingerprintKey, openFingerprintKey, keksOf, hintOf, bindText, schemaAnnotations, summarySchema } = require('./config-core');
/** The DDL of config_snapshots and config_keys, for a service's migrations. */
function configSchema() {
@@@ -15,9 +22,9 @@ function configSchema() {
service text COLLATE "C" NOT NULL,
previous_revision bigint,
state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),
-- values_json text COLLATE "C" NOT NULL, -- the full values, secrets included (never served)
++ values_json text COLLATE "C" NOT NULL, -- the full values; secret-class values are envelopes (never their plaintext)
classification_json text COLLATE "C" NOT NULL,
-- values_checksum text COLLATE "C" NOT NULL, -- sha256 of the full values: internal, never served
++ values_checksum text COLLATE "C" NOT NULL, -- sha256 of the values as served (secrets as markers)
created_at text COLLATE "C" NOT NULL,
created_by text COLLATE "C" NOT NULL,
activated_at text COLLATE "C",
@@@ -32,21 +39,23 @@ CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshot
-- The fingerprint key of each namespace. Never served, never logged.
CREATE TABLE IF NOT EXISTS config_keys (
namespace text COLLATE "C" PRIMARY KEY,
-- hmac_key bytea NOT NULL,
++ hmac_key bytea NOT NULL, -- the 32-byte fingerprint key, or that key wrapped under the current KEK
created_at text COLLATE "C" NOT NULL
);`;
}
async function createPgConfigStore(opts = {}) {
-- const { db, service, namespace, schema = null, validate = null, classify = null, onActivate = null } = opts;
++ const { db, service, namespace, schema = null, validate = null, classify = null, onActivate = null, onChange = null } = opts;
if (!db || typeof db.prepare !== 'function' || typeof db.tx !== 'function') throw new TypeError('createConfigStore: db must be an openvibe-sdk/db handle');
if (!SERVICE_RE.test(String(service || ''))) throw new TypeError('createConfigStore: service must be a service id (live, media, …)');
if (!NAMESPACE_RE.test(String(namespace || '')) || !namespace.startsWith(`${service}.`) || namespace.length > 120) {
throw new TypeError(`createConfigStore: namespace must be <service>.<name>, e.g. ${service}.settings`);
}
++ const keks = keksOf(opts, opts.env || process.env);
if (schema !== null) compileSchema(schema);
if (validate !== null && typeof validate !== 'function') throw new TypeError('createConfigStore: validate must be a function');
if (onActivate !== null && typeof onActivate !== 'function') throw new TypeError('createConfigStore: onActivate must be a function');
++ if (onChange !== null && typeof onChange !== 'function') throw new TypeError('createConfigStore: onChange must be a function');
if (classify !== null && typeof classify !== 'function' && !isPlainObject(classify)) throw new TypeError('createConfigStore: classify must be a map or a function');
if (opts.defaults != null && !isPlainObject(opts.defaults)) throw new TypeError('createConfigStore: defaults must be an object');
const keep = Math.max(1, Math.floor(opts.keep || 50));
@@@ -57,6 +66,85 @@ async function createPgConfigStore(opts = {}) {
const self = { type: 'service', id: service };
const defaults = deepFreeze(clone(opts.defaults || {}));
const tag = `[config] ${namespace}`;
++ const env = opts.env || process.env;
++
++ // ── the schema's owner annotations: env pins, binds and danger keys ──
++ const annotations = schemaAnnotations(schema);
++ const binds = annotations.bind;
++ const dangerKeys = new Set();
++ if (schema && isPlainObject(schema.properties)) {
++ for (const [k, p] of Object.entries(schema.properties)) {
++ if (isPlainObject(p) && p['x-danger'] === true) dangerKeys.add(k);
++ if (isPlainObject(p) && typeof p['x-bind'] === 'string' && classOf(p['x-bind'], undefined) === 'secret') throw new TypeError(`config schema: x-bind of ${k} names the secret key ${p['x-bind']}`);
++ }
++ }
++ function envValue(prop, raw) {
++ const types = prop && prop.type !== undefined ? [].concat(prop.type) : [];
++ if (types.includes('boolean')) return raw === 'true' || raw === '1';
++ if (types.includes('integer') || types.includes('number')) {
++ const n = Number(String(raw).trim());
++ return Number.isFinite(n) ? n : raw;
++ }
++ if (types.includes('array') || types.includes('object')) { try { return JSON.parse(raw); } catch { return raw; } }
++ return raw;
++ }
++ const pins = {};
++ for (const [k, name] of Object.entries(annotations.env)) {
++ const raw = env ? env[name] : undefined;
++ if (raw === undefined) continue;
++ pins[k] = { env: name, value: envValue(schema && schema.properties && schema.properties[k], raw) };
++ }
++ const pinKeys = new Set(Object.keys(pins));
++ const pinValues = () => Object.fromEntries(Object.entries(pins).map(([k, p]) => [k, clone(p.value)]));
++ const emptyEffective = deepFreeze({ ...clone(defaults), ...pinValues() });
++
++ // ── classification and redaction ──
++ const RANK = { public: 0, internal: 1, secret: 2 };
++ function classOf(key, value) {
++ let c;
++ try { c = typeof classify === 'function' ? classify(key, value) : (classify && has(classify, key) ? classify[key] : undefined); } catch { c = 'secret'; }
++ if (c == null) return 'internal';
++ if (!CLASSES.includes(c)) { log.warn(`${tag}: classify gave an invalid class for ${key}; treating it as secret`); return 'secret'; }
++ return c;
++ }
++ /** An envelope is always secret-class; everything else is the policy's call. */
++ const classOfValue = (k, v) => (isEnvelope(v) ? 'secret' : classOf(k, v));
++ const classifyAll = (values) => Object.fromEntries(Object.keys(values).map((k) => [k, classOfValue(k, values[k])]));
++ /** The stricter of what was stored and what the policy says now, so a key made secret later is redacted in old revisions too. */
++ function classesFor(values, stored = {}) {
++ const out = classifyAll(values);
++ for (const k of Object.keys(out)) if (stored[k] && RANK[stored[k]] > RANK[out[k]]) out[k] = stored[k];
++ return out;
++ }
++ /** The marker of a stored value: an envelope carries its fingerprint; plaintext is HMAC'd as before. */
++ function markerOf(v) {
++ const p = isEnvelope(v) ? parseEnvelope(v) : null;
++ if (p) return { redacted: true, fingerprint: p.fingerprint };
++ return { redacted: true, fingerprint: fingerprint(v) };
++ }
++ const redact = (values, cls) => Object.fromEntries(Object.entries(values).map(([k, v]) => [k, cls[k] === 'secret' ? markerOf(v) : v]));
++ /** The fingerprint a marker of this value has, whether the value is stored sealed or plain. */
++ const storedFingerprint = (v) => (isEnvelope(v) ? parseEnvelope(v).fingerprint : fingerprint(v));
++ function secretStrings(...valueSets) {
++ const out = [];
++ for (const values of valueSets) {
++ if (!values) continue;
++ for (const [k, v] of Object.entries(values)) {
++ if (v == null || isEnvelope(v)) continue;
++ if (classOf(k, v) !== 'secret') continue;
++ out.push(typeof v === 'string' ? v : JSON.stringify(v));
++ }
++ }
++ return out.sort((a, b) => b.length - a.length);
++ }
++ const plainStrings = (...valueSets) => {
++ const out = [];
++ for (const values of valueSets) {
++ if (!values) continue;
++ for (const v of Object.values(values)) if (v != null && !isEnvelope(v)) out.push(typeof v === 'string' ? v : JSON.stringify(v));
++ }
++ return out.sort((a, b) => b.length - a.length);
++ };
// config_snapshots and config_keys come from the service's migrations (configSchema()).
const q = {
@@@ -76,52 +164,124 @@ async function createPgConfigStore(opts = {}) {
good: db.prepare('UPDATE config_snapshots SET good = 1 WHERE namespace = ? AND revision = ?'),
reject: db.prepare("UPDATE config_snapshots SET state = 'rejected', error = ?, activated_at = NULL, activated_by = NULL, good = 0 WHERE namespace = ? AND revision = ?"),
prune: db.prepare("DELETE FROM config_snapshots WHERE namespace = ? AND revision <= ? AND state != 'active' AND revision != ?"),
++ reseal: db.prepare('UPDATE config_snapshots SET values_json = ?, classification_json = ?, values_checksum = ? WHERE namespace = ? AND revision = ?'),
++ all: db.prepare('SELECT * FROM config_snapshots WHERE namespace = ? ORDER BY revision'),
};
const tx = async (fn) => await db.tx(fn); // ambient: the store's statements inside join it
// ── the namespace's fingerprint key: made on first use, then read (a racing process keeps the first) ──
++ // With a KEK, config_keys holds that key wrapped under the current KEK (never the key); without one it is
++ // today's plain 32-byte key. The key itself never changes, so markers and checksums survive a KEK rotation.
let hmacKey = null; // loaded by init(): made on first use, then read (a racing process keeps the first)
async function loadKey() {
const read = db.prepare('SELECT hmac_key FROM config_keys WHERE namespace = ?');
++ const insert = db.prepare('INSERT INTO config_keys (namespace, hmac_key, created_at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING');
++ const conditional = db.prepare('UPDATE config_keys SET hmac_key = ? WHERE namespace = ? AND hmac_key = ?');
++ const unconditional = db.prepare('UPDATE config_keys SET hmac_key = ? WHERE namespace = ?');
++ const wrap = (key) => sealFingerprintKey(key, { service, namespace, kekId: keks.current, kek: keks.keys.get(keks.current) });
++ const open = (stored) => openFingerprintKey(stored, { service, namespace, keys: keks.keys });
let row = await read.get(namespace);
if (!row) {
-- await db.prepare('INSERT INTO config_keys (namespace, hmac_key, created_at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING').run(namespace, crypto.randomBytes(32), iso());
++ const fresh = crypto.randomBytes(32);
++ await insert.run(namespace, keks ? wrap(fresh) : fresh, iso());
row = await read.get(namespace);
}
-- const k = row && row.hmac_key ? Buffer.from(row.hmac_key) : null; // Buffer (pg) or Uint8Array (PGlite)
-- if (!k || k.length < 32) throw new Error(`createConfigStore: the fingerprint key of ${namespace} in config_keys is not a 32-byte key`);
-- hmacKey = k;
++ let stored = row && row.hmac_key ? Buffer.from(row.hmac_key) : null; // Buffer (pg) or Uint8Array (PGlite)
++ if (!stored) throw new Error(`createConfigStore: config_keys has no fingerprint key for ${namespace}`);
++ if (isWrappedFingerprintKey(stored)) {
++ if (!keks) throw new Error(`createConfigStore: the fingerprint key of ${namespace} in config_keys is sealed under a KEK, but no KEK is configured`);
++ let opened = open(stored);
++ if (!opened) throw new Error(`createConfigStore: the fingerprint key of ${namespace} in config_keys is not a valid wrapper`);
++ if (opened.kekId !== keks.current) {
++ const replacement = wrap(opened.key);
++ await conditional.run(replacement, namespace, stored);
++ let after = Buffer.from((await read.get(namespace)).hmac_key);
++ let next = isWrappedFingerprintKey(after) ? open(after) : null;
++ if (!next || next.kekId !== keks.current) {
++ // A row this process did not read (or a driver that did not match bytea): rewrap it outright.
++ await unconditional.run(replacement, namespace);
++ after = Buffer.from((await read.get(namespace)).hmac_key);
++ next = isWrappedFingerprintKey(after) ? open(after) : null;
++ }
++ if (!next) throw new Error(`createConfigStore: could not rewrap the fingerprint key of ${namespace} under ${keks.current}`);
++ opened = next;
++ }
++ hmacKey = opened.key;
++ return;
++ }
++ if (stored.length !== 32) throw new Error(`createConfigStore: the fingerprint key of ${namespace} in config_keys is neither a 32-byte key nor a KEK-wrapped key`);
++ if (!keks) { hmacKey = stored; return; }
++ // A KEK was just configured on an existing namespace: wrap the same key, so no marker or checksum moves.
++ let replacement = wrap(stored);
++ await conditional.run(replacement, namespace, stored);
++ let after = Buffer.from((await read.get(namespace)).hmac_key);
++ if (!isWrappedFingerprintKey(after)) {
++ // The conditional did not match: another process may have wrapped it (read again) or replaced
++ // the key, or the driver did not compare bytea. Wrap whichever 32-byte key is there now, once
++ // without the old-value guard, then fail closed.
++ if (after.length === 32) {
++ if (!after.equals(stored)) replacement = wrap(after);
++ await unconditional.run(replacement, namespace);
++ after = Buffer.from((await read.get(namespace)).hmac_key);
++ }
++ if (!isWrappedFingerprintKey(after)) throw new Error(`createConfigStore: could not seal the fingerprint key of ${namespace} under ${keks.current}`);
++ }
++ const opened = open(after);
++ if (!opened) throw new Error(`createConfigStore: the fingerprint key of ${namespace} in config_keys is not a valid wrapper`);
++ hmacKey = opened.key;
}
const fingerprint = (v) => crypto.createHmac('sha256', hmacKey).update(canonical(v)).digest('hex');
const markerFor = (v) => ({ redacted: true, fingerprint: fingerprint(v) });
-- // ── classification and redaction ──
-- const RANK = { public: 0, internal: 1, secret: 2 };
-- function classOf(key, value) {
-- let c;
-- try { c = typeof classify === 'function' ? classify(key, value) : (classify && has(classify, key) ? classify[key] : undefined); } catch { c = 'secret'; }
-- if (c == null) return 'internal';
-- if (!CLASSES.includes(c)) { log.warn(`${tag}: classify gave "${String(c).slice(0, 20)}" for ${key}; treating it as secret`); return 'secret'; }
-- return c;
-- }
-- const classifyAll = (values) => Object.fromEntries(Object.keys(values).map((k) => [k, classOf(k, values[k])]));
-- /** The stricter of what was stored and what the policy says now, so a key made secret later is redacted in old revisions too. */
-- function classesFor(values, stored = {}) {
-- const out = classifyAll(values);
-- for (const k of Object.keys(out)) if (stored[k] && RANK[stored[k]] > RANK[out[k]]) out[k] = stored[k];
-- return out;
-- }
-- const redact = (values, cls) => Object.fromEntries(Object.entries(values).map(([k, v]) => [k, cls[k] === 'secret' ? markerFor(v) : v]));
-- function secretStrings(...valueSets) {
-- const out = [];
-- for (const values of valueSets) {
-- if (!values) continue;
-- for (const [k, v] of Object.entries(values)) {
-- if (classOf(k, v) !== 'secret' || v == null) continue;
-- out.push(typeof v === 'string' ? v : JSON.stringify(v));
++ /**
++ * Open every envelope of a stored values object with the bind each key's x-bind names. A secret that does not
++ * open (another bind, a missing KEK, a copied envelope) is locked: it stays a marker and never reaches get().
++ */
++ function materialize(stored) {
++ const base = {};
++ for (const [k, v] of Object.entries(stored)) if (!isEnvelope(v)) base[k] = v;
++ const opened = {};
++ const locked = new Set();
++ const meta = {};
++ const memo = new Map();
++ function valueOf(key, seen) {
++ if (pinKeys.has(key)) return clone(pins[key].value);
++ if (memo.has(key)) return memo.get(key);
++ if (has(opened, key)) return opened[key];
++ if (has(base, key)) return base[key];
++ if (has(stored, key)) {
++ const r = openKey(key, seen);
++ memo.set(key, r.value);
++ return r.value;
}
++ if (has(defaults, key)) return defaults[key];
++ return undefined;
}
-- return out.sort((a, b) => b.length - a.length);
++ function bindOf(key, seen) {
++ const target = binds[key];
++ if (!target) return '';
++ return bindText(valueOf(target, new Set(seen)));
++ }
++ function openKey(key, seen) {
++ if (!keks) throw Object.assign(new Error('no KEK is configured'), { code: 'config.secret_unreadable' });
++ if (seen.has(key)) throw Object.assign(new Error('x-bind cycle'), { code: 'config.secret_unreadable' });
++ const nextSeen = new Set(seen).add(key);
++ const bind = bindOf(key, nextSeen);
++ const { value, meta: m } = openEnvelope(stored[key], { service, namespace, key, bind, keys: keks.keys });
++ return { value, meta: m };
++ }
++ for (const [k, v] of Object.entries(stored)) {
++ if (!isEnvelope(v)) continue;
++ try {
++ const r = openKey(k, new Set());
++ opened[k] = r.value;
++ meta[k] = r.meta;
++ } catch { locked.add(k); }
++ }
++ const effective = { ...clone(defaults), ...clone(base), ...clone(opened) };
++ for (const [k, p] of Object.entries(pins)) effective[k] = clone(p.value);
++ for (const k of locked) if (!pinKeys.has(k)) delete effective[k];
++ return { opened, locked, effective, meta, stored };
}
function toSnapshot(row) {
@@@ -148,11 +308,23 @@ async function createPgConfigStore(opts = {}) {
}
// ── in-memory state ──
-- let current = null; // { revision, values (stored), effective (frozen), classification }
-- const emptyEffective = defaults;
++ let current = null; // { revision, checksum, stored, opened, locked, effective, classification }
function load(row) {
-- const values = JSON.parse(row.values_json);
-- return { revision: row.revision, checksum: row.values_checksum, values, effective: deepFreeze({ ...clone(defaults), ...clone(values) }), classification: JSON.parse(row.classification_json) };
++ const stored = JSON.parse(row.values_json);
++ const classification = classesFor(stored, JSON.parse(row.classification_json));
++ const mat = materialize(stored);
++ for (const k of mat.locked) log.warn(`${tag}: secret ${k} is set but does not open (it must be entered again for the current value)`);
++ return {
++ revision: row.revision,
++ checksum: row.values_checksum,
++ stored,
++ values: stored,
++ opened: mat.opened,
++ locked: mat.locked,
++ envelopeMeta: mat.meta,
++ effective: deepFreeze(mat.effective),
++ classification,
++ };
}
const effectiveNow = () => (current ? current.effective : emptyEffective);
@@@ -166,29 +338,33 @@ async function createPgConfigStore(opts = {}) {
if (errors.length) return errors;
const size = Buffer.byteLength(canonical(values));
if (size > maxBytes) return [{ path: '', message: `is ${size} bytes; at most ${maxBytes}` }];
-- const effective = { ...defaults, ...values };
-- if (schema !== null) checkSchema(schema, effective, '', errors);
++ const mat = materialize(values);
++ const skip = new Set([...mat.locked].map((k) => pointer(k)));
++ if (schema !== null) checkSchema(schema, mat.effective, '', errors, skip);
if (validate) {
let r;
-- try { r = validate(effective); } catch (err) { r = [{ path: '', message: errText(err) }]; }
++ try { r = validate(mat.effective); } catch (err) { r = [{ path: '', message: errText(err) }]; }
if (r && typeof r.then === 'function') throw new TypeError('createConfigStore: validate must be synchronous (check asynchronously in onActivate)');
errors.push(...validateResult(r));
}
-- const secrets = secretStrings(values, effectiveNow());
++ const secrets = [...secretStrings(values, current && current.opened, current ? current.effective : emptyEffective), ...plainStrings(mat.opened)].sort((a, b) => b.length - a.length);
return errors.map((e) => ({ path: e.path, message: scrub(e.message, secrets) }));
}
const invalid = (errors, revision) => new ConfigError('config.invalid', `invalid configuration: ${summarize(errors)}`.slice(0, ERROR_MAX), { status: 422, errors, revision });
-- /** values (+ merge/unset) as the complete values a new revision would hold. A redaction marker keeps the value it stands for. */
++ /** values (+ merge/unset) as the complete stored form a new revision would hold. A marker keeps the value it stands for. */
function resolve(input, { merge = false, unset = [] } = {}) {
if (!isPlainObject(input)) throw invalid([{ path: '', message: 'must be an object' }]);
if (!Array.isArray(unset) || !unset.every((k) => typeof k === 'string')) throw invalid([{ path: '', message: 'unset must be a list of keys' }]);
-- const base = merge && current ? clone(current.values) : {};
++ for (const k of [...Object.keys(input), ...unset]) {
++ if (pinKeys.has(k)) throw new ConfigError('config.pinned_in_env', `${k} is pinned by ${pins[k].env} and is read-only`, { status: 409 });
++ }
++ const base = merge && current ? clone(current.stored) : {};
const errors = [];
for (const [k, v] of Object.entries(input)) {
if (!validKey(k)) { errors.push({ path: pointer(k), message: 'is not a valid key (letters, digits, _ . : -; at most 128)' }); continue; }
if (!isMarker(v)) { base[k] = v; continue; }
-- if (current && has(current.values, k) && sameHex(fingerprint(current.values[k]), v.fingerprint)) base[k] = clone(current.values[k]);
++ if (current && has(current.stored, k) && sameHex(storedFingerprint(current.stored[k]), v.fingerprint)) base[k] = clone(current.stored[k]);
else if (has(defaults, k) && sameHex(fingerprint(defaults[k]), v.fingerprint)) delete base[k];
else errors.push({ path: pointer(k), message: 'is a redacted value that is not the current one; send the value itself' });
}
@@@ -197,6 +373,31 @@ async function createPgConfigStore(opts = {}) {
return base;
}
++ /** Seal the new plaintext secrets of a complete values object; envelopes already sealed are carried unchanged. */
++ function sealValues(values) {
++ const classification = classesFor(values, current ? current.classification : {});
++ const mat = materialize(values);
++ const out = {};
++ for (const [k, v] of Object.entries(values)) {
++ if (classification[k] !== 'secret' || !keks || isEnvelope(v)) { out[k] = v; continue; }
++ const target = binds[k];
++ let bindVal;
++ if (target) {
++ if (has(values, target) && isEnvelope(values[target])) throw new ConfigError('config.bind_secret', `the x-bind target of ${k} is secret-class`, { status: 422 });
++ if (pinKeys.has(target)) bindVal = pins[target].value;
++ else if (has(mat.effective, target)) bindVal = mat.effective[target];
++ else if (has(defaults, target)) bindVal = defaults[target];
++ if (bindVal !== undefined && classOfValue(target, bindVal) === 'secret') throw new ConfigError('config.bind_secret', `the x-bind target of ${k} is secret-class`, { status: 422 });
++ }
++ const bind = bindText(bindVal);
++ out[k] = sealEnvelope({
++ service, namespace, key: k, value: v, bind,
++ fingerprint: fingerprint(v), hint: hintOf(v), kekId: keks.current, kek: keks.keys.get(keks.current),
++ });
++ }
++ return { values: out, classification };
++ }
++
function changedKeys(before, after) {
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
return [...keys].filter((k) => has(before, k) !== has(after, k) || !same(before[k], after[k])).sort();
@@@ -217,13 +418,15 @@ async function createPgConfigStore(opts = {}) {
}
async function insertRow(values, { state, actor, reason, previous, copiedFrom = null, activatedAt = null, good = 0 }) {
++ const sealed = sealValues(values);
++ const checksum = sha256(canonical(redact(sealed.values, sealed.classification)));
let row;
await tx(async () => {
const revision = (await q.max.get(namespace)).n + 1;
await q.insert.run({
namespace, revision, service, previous_revision: previous, state,
-- values_json: JSON.stringify(values), classification_json: JSON.stringify(classifyAll(values)),
-- values_checksum: sha256(canonical(values)), created_at: iso(), created_by: JSON.stringify(actor),
++ values_json: JSON.stringify(sealed.values), classification_json: JSON.stringify(sealed.classification),
++ values_checksum: checksum, created_at: iso(), created_by: JSON.stringify(actor),
activated_at: activatedAt, activated_by: activatedAt ? JSON.stringify(actor) : null,
reason, copied_from: copiedFrom, good,
});
@@@ -300,6 +503,12 @@ async function createPgConfigStore(opts = {}) {
return run;
}
++ async function notifyChange(keys, actor, revision) {
++ if (!onChange) return;
++ try { await onChange({ namespace, keys, actor, revision }); }
++ catch (err) { log.warn(`${tag}: onChange failed: ${scrub(errText(err), [...secretStrings(current && current.values, current && current.effective), ...plainStrings(current && current.opened)])}`); }
++ }
++
async function activateNow(revision, { actor, rollingBack = false } = {}) {
const by = actorOf(actor);
const row = await q.row.get(namespace, revision);
@@@ -330,7 +539,7 @@ async function createPgConfigStore(opts = {}) {
try {
await onActivate(next.effective, prev ? prev.effective : null, { namespace, revision, previous_revision: prev ? prev.revision : null, restoring: false });
} catch (err) {
-- const message = scrub(`onActivate: ${errText(err)}`, secretStrings(next.values, prev && prev.values));
++ const message = scrub(`onActivate: ${errText(err)}`, [...secretStrings(next.values, prev && prev.values, next.effective, prev && prev.effective), ...plainStrings(next.opened, prev && prev.opened)]);
await tx(async () => {
await q.reject.run(message, namespace, revision);
if (prev) await q.setState.run('active', namespace, prev.revision);
@@@ -341,13 +550,14 @@ async function createPgConfigStore(opts = {}) {
try {
await onActivate(prev.effective, next.effective, { namespace, revision: prev.revision, previous_revision: revision, restoring: true });
} catch (again) {
-- log.error(`${tag}: re-applying revision ${prev.revision} failed too: ${scrub(errText(again), secretStrings(next.values, prev.values))}`);
++ log.error(`${tag}: re-applying revision ${prev.revision} failed too: ${scrub(errText(again), [...secretStrings(next.values, prev.values, next.effective, prev.effective), ...plainStrings(next.opened, prev.opened)])}`);
}
}
throw new ConfigError('config.activation_failed', message, { status: 422, revision, cause: err });
}
await q.good.run(namespace, revision);
}
++ await notifyChange(changed, by, revision);
await prune();
log.info(`${tag}: revision ${revision} active${prev ? ` (was ${prev.revision})` : ''} by ${who(by)}; changed ${listKeys(changed)}`);
return toSnapshot(await q.row.get(namespace, revision));
@@@ -361,7 +571,7 @@ async function createPgConfigStore(opts = {}) {
if (errors.length) throw invalid(errors);
const row = await insertRow(next, { state: 'proposed', actor, reason, previous: current ? current.revision : null });
await prune();
-- log.info(`${tag}: revision ${row.revision} proposed by ${who(actor)}; changes ${listKeys(changedKeys(effectiveNow(), { ...defaults, ...next }))}`);
++ log.info(`${tag}: revision ${row.revision} proposed by ${who(actor)}; changes ${listKeys(changedKeys(effectiveNow(), materialize(next).effective))}`);
return toSnapshot(row);
}
@@@ -380,10 +590,124 @@ async function createPgConfigStore(opts = {}) {
return rev;
}
++ /** The marker of one secret-class key: the active revision's row, or an env pin / default that no row holds. */
++ async function secretMarker(key) {
++ const row = await q.active.get(namespace);
++ const values = row ? JSON.parse(row.values_json) : {};
++ let value;
++ let fromRow = false;
++ if (pinKeys.has(key)) value = pins[key].value;
++ else if (has(values, key)) { value = values[key]; fromRow = true; }
++ else if (has(defaults, key)) value = defaults[key];
++ else return null;
++ const classification = fromRow ? classesFor(values, JSON.parse(row.classification_json)) : { [key]: classOfValue(key, value) };
++ if (classification[key] !== 'secret') return null;
++ const envelope = isEnvelope(value) ? parseEnvelope(value) : null;
++ const readable = !envelope && current && has(current.effective, key) ? current.effective[key] : undefined;
++ const plain = readable !== undefined ? readable : value;
++ const fp = envelope ? envelope.fingerprint : fingerprint(plain);
++ let boundTo = envelope ? envelope.bind : null;
++ if (!envelope) {
++ const target = binds[key];
++ const mat = materialize(values);
++ const v = target && has(mat.effective, target) ? mat.effective[target] : undefined;
++ boundTo = target && !(v !== undefined && classOfValue(target, v) === 'secret') ? bindText(v) : '';
++ }
++ const activatedAt = fromRow ? (row.activated_at || row.created_at) : null;
++ const setBy = fromRow ? (row.activated_by ? JSON.parse(row.activated_by) : JSON.parse(row.created_by)) : null;
++ return {
++ redacted: true,
++ fingerprint: fp,
++ hint: envelope ? (envelope.hint || null) : hintOf(plain),
++ set_at: activatedAt || null,
++ set_by: setBy,
++ bound_to: boundTo || null,
++ };
++ }
++
++ async function resealNow() {
++ if (!keks) throw new ConfigError('config.no_kek', 'reseal needs a KEK (opts.keks or OV_CONFIG_KEKS)', { status: 409 });
++ const rows = await q.all.all(namespace);
++ const report = { namespace, kek: keks.current, revisions: rows.length, resealed: 0, current: 0, plaintext: 0, locked: 0, errors: [] };
++ const updates = [];
++ for (const row of rows) {
++ const values = JSON.parse(row.values_json);
++ const storedCls = JSON.parse(row.classification_json);
++ const cls = classesFor(values, storedCls);
++ const mat = materialize(values);
++ const next = {};
++ let changed = false;
++ for (const [k, v] of Object.entries(values)) {
++ if (cls[k] !== 'secret') { next[k] = v; continue; }
++ try {
++ const target = binds[k];
++ const bindVal = target ? (pinKeys.has(target) ? pins[target].value : (has(mat.effective, target) ? mat.effective[target] : undefined)) : undefined;
++ if (target && has(values, target) && isEnvelope(values[target])) throw new ConfigError('config.bind_secret', `the x-bind target of ${k} is secret-class`, { status: 422 });
++ if (target && bindVal !== undefined && classOfValue(target, bindVal) === 'secret') throw new ConfigError('config.bind_secret', `the x-bind target of ${k} is secret-class`, { status: 422 });
++ const bind = bindText(bindVal);
++ if (isEnvelope(v)) {
++ const p = parseEnvelope(v);
++ const { value } = openEnvelope(v, { service, namespace, key: k, bind, keys: keks.keys });
++ const fpNow = fingerprint(value);
++ if (p.kekId === keks.current && fpNow === p.fingerprint) { next[k] = v; report.current++; continue; }
++ next[k] = sealEnvelope({
++ service, namespace, key: k, value, bind,
++ fingerprint: fpNow, hint: p.hint || hintOf(value),
++ kekId: keks.current, kek: keks.keys.get(keks.current),
++ });
++ changed = true;
++ report.resealed++;
++ } else {
++ next[k] = sealEnvelope({
++ service, namespace, key: k, value: v, bind,
++ fingerprint: fingerprint(v), hint: hintOf(v), kekId: keks.current, kek: keks.keys.get(keks.current),
++ });
++ changed = true;
++ report.plaintext++;
++ }
++ } catch (err) {
++ next[k] = v;
++ report.locked++;
++ report.errors.push({ revision: row.revision, key: k, reason: scrub(errText(err)) });
++ }
++ }
++ const nextJson = JSON.stringify(next);
++ const clsJson = JSON.stringify(cls);
++ if (changed || nextJson !== row.values_json || clsJson !== row.classification_json) {
++ updates.push({ revision: row.revision, values_json: nextJson, classification_json: clsJson, values_checksum: sha256(canonical(redact(next, cls))) });
++ }
++ }
++ if (updates.length) {
++ await tx(async () => {
++ for (const u of updates) await q.reseal.run(u.values_json, u.classification_json, u.values_checksum, namespace, u.revision);
++ });
++ }
++ if (current) current = load(await q.row.get(namespace, current.revision));
++ if (report.locked) log.warn(`${tag}: reseal left ${report.locked} secret(s) locked (${report.errors.slice(0, 3).map((e) => `${e.key}@${e.revision}`).join(', ')})`);
++ log.info(`${tag}: reseal to ${keks.current}: ${report.resealed} under an old KEK, ${report.plaintext} plaintext, ${report.current} current, ${report.locked} locked`);
++ return report;
++ }
++
++ /** The summary's schema: a clone whose secret-class properties never carry a default, examples, enum or const. */
++ function schemaForSummary() {
++ const isSecret = (k) => {
++ if (current && current.classification[k] === 'secret') return true;
++ if (pinKeys.has(k) && classOf(k, pins[k].value) === 'secret') return true;
++ if (classOf(k, undefined) === 'secret') return true;
++ const p = schema && schema.properties && schema.properties[k];
++ return !!(isPlainObject(p) && has(p, 'default') && classOf(k, p.default) === 'secret');
++ };
++ return summarySchema(schema, isSecret);
++ }
++
const store = {
service,
namespace,
-- /** The active values over the defaults (frozen), or one of them. Never reads the database. */
++ /** Whether secret-class values are sealed with a KEK (D11). */
++ get sealed() { return !!keks; },
++ /** The current KEK id, or null when unsealed. */
++ get kek() { return keks ? keks.current : null; },
++ /** The active values over the defaults and env pins (frozen), or one of them. Never reads the database. */
get(key) { const e = effectiveNow(); return key === undefined ? e : e[key]; },
/** The active revision number, or null. */
revision() { return current ? current.revision : null; },
@@@ -412,18 +736,92 @@ async function createPgConfigStore(opts = {}) {
},
/** Keys whose effective value would change: for new values (+ merge/unset), or for a revision's. */
async changes(values, o = {}) {
-- const next = o.revision != null ? JSON.parse((await q.row.get(namespace, Number(o.revision)) || { values_json: '{}' }).values_json) : resolve(values, o);
-- return changedKeys(effectiveNow(), { ...defaults, ...next });
++ if (o.revision != null) {
++ const row = await q.row.get(namespace, Number(o.revision));
++ const stored = row ? JSON.parse(row.values_json) : {};
++ return changedKeys(effectiveNow(), materialize(stored).effective);
++ }
++ const next = resolve(values, o);
++ return changedKeys(effectiveNow(), materialize(next).effective);
++ },
++ /** The classification a change would give its keys (the active one for keys it removes). */
++ async classes(values, o = {}) {
++ let next;
++ if (o.revision != null) {
++ const row = await q.row.get(namespace, Number(o.revision));
++ next = row ? JSON.parse(row.values_json) : {};
++ } else next = resolve(values, o);
++ const out = classesFor(next, current ? current.classification : {});
++ if (current) for (const k of Object.keys(current.classification)) if (!has(out, k)) out[k] = current.classification[k];
++ return out;
++ },
++ isDanger(key) { return dangerKeys.has(key); },
++ isPinned(key) { return pinKeys.has(key); },
++ /** The classification policy for one value (an envelope is secret). */
++ classify(key, value) { return classOfValue(key, value); },
++ /** The marker of one secret-class key of the active revision, or null. */
++ secretMarker,
++ /** Write one secret through the write-only path: a new revision holding its envelope. Returns the marker. */
++ setSecret(key, value, o = {}) {
++ return serialized(async () => {
++ if (typeof key !== 'string' || !validKey(key)) throw invalid([{ path: pointer(key), message: 'is not a valid key (letters, digits, _ . : -; at most 128)' }]);
++ if (pinKeys.has(key)) throw new ConfigError('config.pinned_in_env', `${key} is pinned by ${pins[key].env} and is read-only`, { status: 409 });
++ if (classOfValue(key, value) !== 'secret') throw new ConfigError('config.not_secret', `${key} is not a secret-class key`, { status: 422 });
++ if (value === undefined) throw invalid([{ path: pointer(key), message: 'is required' }]);
++ if (looksRedacted(value)) throw invalid([{ path: pointer(key), message: 'a secret value cannot be a redaction marker' }]);
++ const actor = actorOf(o.actor);
++ const next = current ? clone(current.stored) : {};
++ next[key] = value;
++ const errors = problems(next);
++ if (errors.length) throw invalid(errors);
++ const row = await insertRow(next, { state: 'proposed', actor, reason: reasonOf(o.reason) || 'secret set', previous: current ? current.revision : null });
++ await activateNow(row.revision, { actor });
++ return await secretMarker(key);
++ });
},
-- /** What /api/admin/config shows: the active revision, its effective values redacted, and the last-known-good. */
++ /** Remove one secret through the write-only path. Returns the marker of the removed (empty) state. */
++ deleteSecret(key, o = {}) {
++ return serialized(async () => {
++ if (typeof key !== 'string' || !validKey(key)) throw invalid([{ path: pointer(key), message: 'is not a valid key (letters, digits, _ . : -; at most 128)' }]);
++ if (pinKeys.has(key)) throw new ConfigError('config.pinned_in_env', `${key} is pinned by ${pins[key].env} and is read-only`, { status: 409 });
++ if (!current || !has(current.stored, key)) throw new ConfigError('config.secret_not_set', `${key} is not set`, { status: 404 });
++ if (current.classification[key] !== 'secret') throw new ConfigError('config.not_secret', `${key} is not a secret-class key`, { status: 422 });
++ const actor = actorOf(o.actor);
++ const next = clone(current.stored);
++ delete next[key];
++ const errors = problems(next);
++ if (errors.length) throw invalid(errors);
++ const row = await insertRow(next, { state: 'proposed', actor, reason: reasonOf(o.reason) || 'secret deleted', previous: current.revision });
++ await activateNow(row.revision, { actor });
++ return { redacted: true, fingerprint: null, hint: null, set_at: null, set_by: actor, bound_to: null };
++ });
++ },
++ /** Move every plaintext secret and every envelope under an older KEK to the current KEK, revision by revision. */
++ reseal() { return serialized(resealNow); },
++ /** What /api/admin/config shows: the active revision, its effective values redacted, the schema, pins and state. */
async summary() {
-- const effective = effectiveNow();
-- const classification = classesFor(effective, current ? current.classification : {});
++ const values = {};
++ for (const [k, v] of Object.entries(current ? current.stored : {})) values[k] = clone(v);
++ for (const k of Object.keys(defaults)) if (!has(values, k)) values[k] = clone(defaults[k]);
++ for (const [k, p] of Object.entries(pins)) values[k] = clone(p.value);
++ const classification = classesFor(values, current ? current.classification : {});
++ const shown = redact(values, classification);
++ const secrets = {};
++ for (const k of Object.keys(classification)) {
++ if (classification[k] !== 'secret' || !has(values, k)) continue;
++ const m = await store.secretMarker(k);
++ if (m) secrets[k] = m;
++ }
return {
service, namespace,
revision: current ? current.revision : null,
-- values: redact(effective, classification),
++ values: shown,
classification,
++ sealed: !!keks,
++ kek: keks ? keks.current : null,
++ pins: Object.fromEntries(Object.entries(pins).map(([k, p]) => [k, { env: p.env, pinned: true }])),
++ secrets,
++ schema: schemaForSummary(),
active: current ? await store.snapshot(current.revision) : null,
last_known_good: await store.lastKnownGood(),
};
ddiff --git a/config.js b/config.js
iindex 7f1eeec..4e17f9c 100644
---- a/config.js
++++ b/config.js
@@@ -1,6 +1,6 @@
'use strict';
/** Configuration store on an openvibe-sdk/db handle. The store is ready when the promise resolves. */
--const { fromRows, canonical, ConfigError, subjectRef, isPlainObject, makeLog, scrub, errText } = require('./config-core');
++const { fromRows, canonical, ConfigError, subjectRef, isPlainObject, isMarker, makeLog, scrub, errText } = require('./config-core');
function createConfigStore(opts = {}) {
if (!opts.db || typeof opts.db.prepare !== 'function' || typeof opts.db.tx !== 'function') {
@@@ -55,17 +55,23 @@ function readBody(req, limit = 256 * 1024) {
}
/**
-- * adminRoutes(stores, { requireAdmin, basePath, actor, authorize, router, log })
-- * GET <base> every namespace: active revision, values (redacted), last-known-good
-- * GET <base>/:namespace one of them
-- * GET <base>/:namespace/history ?limit (≤200) &before=<revision>, newest first
-- * POST <base>/:namespace { values, reason, merge?, unset? } → apply; 422 with errors
-- * POST <base>/:namespace/rollback { to?, reason } → the new revision
++ * adminRoutes(stores, { requireAdmin, basePath, actor, authorize, requireFresh, router, log })
++ * GET <base> every namespace: active revision, values (redacted), last-known-good
++ * GET <base>/:namespace one of them
++ * GET <base>/:namespace/history ?limit (≤200) &before=<revision>, newest first
++ * POST <base>/:namespace { values, reason, merge?, unset? } → apply; 422 with errors
++ * POST <base>/:namespace/rollback { to?, reason } → the new revision
++ * PUT <base>/:namespace/secrets/:key { value, reason } → the marker (write-only)
++ * DELETE <base>/:namespace/secrets/:key { reason } → the empty marker (write-only)
* requireAdmin (a middleware or a list) guards every handler, mounted or called directly. actor(req)
* names who acts (default: req.user.subject_id and friends; none → 403). authorize(req, { action:
-- * 'read' | 'write', namespace, keys }) may refuse more (false → 403). No Express dependency: pass
-- * `router` (anything with get/post) or call mount(router), use the handlers, or handle(req, res, next)
-- * for a plain http server. The body is req.body when a JSON parser ran, else read from the request.
++ * 'read' | 'write', namespace, keys }) may refuse more (false → 403). requireFresh(req, { namespace,
++ * keys }) runs before a secret write and before a change to an x-danger key; false → 403
++ * config.fresh_required. A normal POST may not carry a secret-class value (422
++ * config.secret_write_only) or an env-pinned key (409 config.pinned_in_env). No Express dependency:
++ * pass `router` (anything with get/post/put/delete) or call mount(router), use the handlers, or
++ * handle(req, res, next) for a plain http server. The body is req.body when a JSON parser ran, else
++ * read from the request.
*/
function adminRoutes(stores, o = {}) {
const list = [].concat(stores || []);
@@@ -80,6 +86,8 @@ function adminRoutes(stores, o = {}) {
const basePath = (o.basePath || '/api/admin/config').replace(/\/+$/, '');
const actorOf = o.actor || defaultActor;
const authorize = o.authorize || null;
++ const requireFresh = o.requireFresh || null;
++ if (requireFresh !== null && typeof requireFresh !== 'function') throw new TypeError('adminRoutes: requireFresh must be a function');
const log = makeLog(o.log);
const allowed = async (req, ctx) => !authorize || (await authorize(req, ctx)) !== false;
@@@ -90,6 +98,18 @@ function adminRoutes(stores, o = {}) {
return s;
}
const queryOf = (req) => req.query || Object.fromEntries(new URL(req.url || '/', 'http://x').searchParams);
++ const keyList = (keys) => keys.slice(0, 10).join(', ') + (keys.length > 10 ? ` (+${keys.length - 10})` : '');
++ const keyOf = (req) => {
++ let key = (req.params && req.params.key) || '';
++ try { key = decodeURIComponent(key); } catch { /* keep it as it is */ }
++ return key;
++ };
++ /** requireFresh for a secret or x-danger write; absent means no extra gate (nothing new is on). */
++ async function fresh(req, s, keys) {
++ if (!requireFresh) return;
++ const ok = await requireFresh(req, { namespace: s.namespace, keys });
++ if (ok === false) throw new ConfigError('config.fresh_required', 'a fresh sign-in is required for this change', { status: 403 });
++ }
const handlers = {
async list(req, res) {
@@@ -120,6 +140,13 @@ function adminRoutes(stores, o = {}) {
const opts = { merge: body.merge === true, unset: body.unset || [], actor, reason: body.reason };
const keys = await s.changes(body.values, opts);
if (!(await allowed(req, { action: 'write', namespace: s.namespace, keys }))) throw new ConfigError('config.forbidden', 'not allowed to change these keys', { status: 403 });
++ const classes = await s.classes(body.values, opts);
++ // A secret value may not even be carried unchanged through the normal route; a marker may.
++ const carried = Object.entries(body.values).filter(([k, v]) => !isMarker(v) && s.classify(k, v) === 'secret').map(([k]) => k);
++ const secretKeys = [...new Set([...keys.filter((k) => classes[k] === 'secret'), ...carried])];
++ if (secretKeys.length) throw new ConfigError('config.secret_write_only', `secret-class keys are write-only through /secrets: ${keyList(secretKeys)}`, { status: 422 });
++ const danger = keys.filter((k) => s.isDanger(k));
++ if (danger.length) await fresh(req, s, danger);
send(res, 200, await s.apply(body.values, opts));
},
async rollback(req, res) {
@@@ -129,9 +156,35 @@ function adminRoutes(stores, o = {}) {
if (body.to != null && !Number.isInteger(body.to)) throw new ConfigError('config.bad_request', 'to must be a revision number', { status: 400 });
const actor = await actorOf(req);
if (!subjectRef(actor)) throw new ConfigError('config.actor_required', 'the request does not name the acting subject', { status: 403 });
-- const keys = await s.changes(null, { revision: await s.rollbackTarget(body.to) });
++ const target = await s.rollbackTarget(body.to);
++ const keys = await s.changes(null, { revision: target });
if (!(await allowed(req, { action: 'write', namespace: s.namespace, keys }))) throw new ConfigError('config.forbidden', 'not allowed to change these keys', { status: 403 });
-- send(res, 200, await s.rollback({ to: body.to, reason: body.reason, actor }));
++ const classes = await s.classes(null, { revision: target });
++ const gated = [...new Set([...keys.filter((k) => classes[k] === 'secret'), ...keys.filter((k) => s.isDanger(k))])];
++ if (gated.length) await fresh(req, s, gated);
++ send(res, 200, await s.rollback({ to: target, reason: body.reason, actor }));
++ },
++ async secretPut(req, res) {
++ const s = storeFor(req);
++ const key = keyOf(req);
++ if (!(await allowed(req, { action: 'write', namespace: s.namespace, keys: [key] }))) throw new ConfigError('config.forbidden', 'not allowed to change this key', { status: 403 });
++ const body = await readBody(req);
++ if (!isPlainObject(body) || !Object.prototype.hasOwnProperty.call(body, 'value')) throw new ConfigError('config.bad_request', 'the body must be { value, reason }', { status: 400 });
++ const actor = await actorOf(req);
++ if (!subjectRef(actor)) throw new ConfigError('config.actor_required', 'the request does not name the acting subject', { status: 403 });
++ await fresh(req, s, [key]);
++ send(res, 200, await s.setSecret(key, body.value, { actor, reason: body.reason }));
++ },
++ async secretDelete(req, res) {
++ const s = storeFor(req);
++ const key = keyOf(req);
++ if (!(await allowed(req, { action: 'write', namespace: s.namespace, keys: [key] }))) throw new ConfigError('config.forbidden', 'not allowed to change this key', { status: 403 });
++ const body = await readBody(req);
++ if (!isPlainObject(body)) throw new ConfigError('config.bad_request', 'the body must be { reason }', { status: 400 });
++ const actor = await actorOf(req);
++ if (!subjectRef(actor)) throw new ConfigError('config.actor_required', 'the request does not name the acting subject', { status: 403 });
++ await fresh(req, s, [key]);
++ send(res, 200, await s.deleteSecret(key, { actor, reason: body.reason }));
},
};
@@@ -161,12 +214,14 @@ function adminRoutes(stores, o = {}) {
{ method: 'get', path: `${basePath}/:namespace/history`, handler: out.history },
{ method: 'post', path: `${basePath}/:namespace`, handler: out.apply },
{ method: 'post', path: `${basePath}/:namespace/rollback`, handler: out.rollback },
++ { method: 'put', path: `${basePath}/:namespace/secrets/:key`, handler: out.secretPut },
++ { method: 'delete', path: `${basePath}/:namespace/secrets/:key`, handler: out.secretDelete },
];
function mount(router) {
for (const r of routes) router[r.method](r.path, r.handler);
return router;
}
-- /** For a plain http server: dispatches the five routes, calls next() (or answers 404) for anything else. */
++ /** For a plain http server: dispatches the seven routes, calls next() (or answers 404) for anything else. */
function handle(req, res, next) {
const url = new URL(req.url || '/', 'http://x');
const method = String(req.method || 'GET').toLowerCase();
@@@ -179,11 +234,15 @@ function adminRoutes(stores, o = {}) {
else if (parts.length === 1 && method === 'post') m = ['apply', parts[0]];
else if (parts.length === 2 && parts[1] === 'history' && method === 'get') m = ['history', parts[0]];
else if (parts.length === 2 && parts[1] === 'rollback' && method === 'post') m = ['rollback', parts[0]];
++ else if (parts.length === 3 && parts[1] === 'secrets' && method === 'put') m = ['secretPut', parts[0], parts[2]];
++ else if (parts.length === 3 && parts[1] === 'secrets' && method === 'delete') m = ['secretDelete', parts[0], parts[2]];
}
if (!m) return typeof next === 'function' ? next() : sendProblem(res, 404, 'config.route_not_found', { detail: 'no such configuration route' });
let ns = m[1] || '';
try { ns = decodeURIComponent(ns); } catch { /* keep it as it is */ }
-- req.params = { ...(req.params || {}), namespace: ns };
++ const params = { ...(req.params || {}), namespace: ns };
++ if (m[2] !== undefined) { let key = m[2]; try { key = decodeURIComponent(key); } catch { /* keep it as it is */ } params.key = key; }
++ req.params = params;
if (!req.query) req.query = Object.fromEntries(url.searchParams);
return out[m[0]](req, res, next);
}
ddiff --git a/package-lock.json b/package-lock.json
iindex f0b2921..728c88c 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.109.2",
++ "version": "4.110.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.109.2",
++ "version": "4.110.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 25c7e14..cb29ed0 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.109.2",
++ "version": "4.110.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/test/config-pg.test.js b/test/config-pg.test.js
iindex a87da48..cd50289 100644
---- a/test/config-pg.test.js
++++ b/test/config-pg.test.js
@@@ -534,16 +534,20 @@ function counting(db) {
res = await call('POST', '/api/admin/config/live.site_settings', { body: { values: { youtube_api_key: 'AIzaNEW' }, merge: true } });
assertProblem(res.body, 403, 'config.forbidden');
assert.strictEqual(live.get('youtube_api_key'), SECRET, 'authorize() refused a secret change');
-- res = await call('POST', '/api/admin/config/live.site_settings', { user: 'owner', body: { values: { youtube_api_key: 'AIzaNEW' }, merge: true } });
-- assert.strictEqual(res.status, 200);
-- assert.strictEqual(res.body.created_by.id, USER2);
++ res = await call('PUT', '/api/admin/config/live.site_settings/secrets/youtube_api_key', { user: 'owner', body: { value: 'AIzaNEW', reason: 'owner key' } });
++ assert.strictEqual(res.status, 200, JSON.stringify(res.body));
++ assert.deepStrictEqual(Object.keys(res.body).sort(), ['bound_to', 'fingerprint', 'hint', 'redacted', 'set_at', 'set_by']);
++ assert.strictEqual(res.body.set_by.id, USER2);
assert.strictEqual(live.get('youtube_api_key'), 'AIzaNEW');
++ res = await call('POST', '/api/admin/config/live.site_settings', { user: 'owner', body: { values: { youtube_api_key: 'still-write-only' }, merge: true } });
++ assertProblem(res.body, 422, 'config.secret_write_only');
res = await call('POST', '/api/admin/config/live.site_settings', { user: 'nosubject', body: { values: { mode: 'open' }, merge: true } });
assertProblem(res.body, 403, 'config.actor_required');
-- failNext = new Error(`Media said no to ${SECRET}`);
-- res = await call('POST', '/api/admin/config/live.site_settings', { user: 'owner', body: { values: { youtube_api_key: SECRET, mode: 'open' }, merge: true } });
++ failNext = new Error('Media said no to AIzaNEW');
++ res = await call('POST', '/api/admin/config/live.site_settings', { user: 'owner', body: { values: { mode: 'open' }, merge: true } });
assertProblem(res.body, 422, 'config.activation_failed');
assert.strictEqual(live.get('mode'), 'invite', 'the failure restored the previous values');
++ assert.ok(!JSON.stringify(res.body).includes('AIzaNEW'), 'the activation error is scrubbed');
res = await call('POST', '/api/admin/config/live.site_settings/rollback', { body: { reason: 'undo the key' } });
assertProblem(res.body, 403, 'config.forbidden');
@@@ -625,10 +629,11 @@ function counting(db) {
direct.list({ url: '/api/admin/config', admin: true }, allowedRes);
await sleep(5);
assert.strictEqual(allowedRes.statusCode, 200);
-- assert.strictEqual(direct.routes.length, 5);
++ assert.strictEqual(direct.routes.length, 7);
assert.deepStrictEqual(direct.routes.map((x) => `${x.method.toUpperCase()} ${x.path}`), [
'GET /api/admin/config', 'GET /api/admin/config/:namespace', 'GET /api/admin/config/:namespace/history',
'POST /api/admin/config/:namespace', 'POST /api/admin/config/:namespace/rollback',
++ 'PUT /api/admin/config/:namespace/secrets/:key', 'DELETE /api/admin/config/:namespace/secrets/:key',
]);
assertNoLeak(logs.join('\n'), (await leakForms(db)).concat(await leakForms(legacyDb)), 'the whole log');
ddiff --git a/test/config-secrets.test.js b/test/config-secrets.test.js
nnew file mode 100644
iindex 0000000..09ad6b5
---- /dev/null
++++ b/test/config-secrets.test.js
@@@ -0,0 +1,488 @@
++'use strict';
++// D11 S2: sealed secrets, x-bind, write-only secret routes, x-env pins, the schema in the summary, requireFresh
++// and onChange, reseal() and rotation. The made-up values below exist to prove no form of them leaves the store:
++// not a summary, snapshot, history, error, log or values_json.
++const assert = require('assert');
++const crypto = require('crypto');
++let createDb;
++try { ({ createDb } = require('openvibe-sdk/db')); } catch { console.log('config-secrets: skipped (openvibe-sdk not installed)'); process.exit(0); }
++const express = require('express');
++const { configSchema } = require('../config-pg');
++const { createConfigStore, adminRoutes } = require('../config');
++const { openFingerprintKey } = require('../config-core');
++
++const USER = { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' };
++const USER2 = { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR' };
++const SECRET = 'MADE-UP-KEY-7f3a9c2e1b';
++const SECRET2 = 'MADE-UP-ROTATED-2b4d6f8a';
++const ENV_SECRET = 'MADE-UP-PINNED-c3d5e7f9';
++const K1 = crypto.randomBytes(32);
++const K2 = crypto.randomBytes(32);
++const b64 = (b) => b.toString('base64');
++const keks1 = { current: 'k1', keys: { k1: K1 } };
++const keks12 = { current: 'k2', keys: { k1: K1, k2: K2 } };
++const logs = [];
++const log = (m, level) => logs.push(`${level} ${m}`);
++
++const SCHEMA = {
++ $schema: 'https://json-schema.org/draft/2020-12/schema', title: 'Live provider settings',
++ type: 'object', additionalProperties: false,
++ properties: {
++ provider: { type: 'string', title: 'Provider', description: 'p', 'x-group': 'providers' },
++ base_url: { type: 'string' },
++ provider_key: { type: 'string', title: 'Provider key', description: 'write only', 'x-group': 'providers', 'x-bind': 'base_url' },
++ api_key: { type: 'string', title: 'API key', 'x-group': 'providers' },
++ danger_switch: { type: 'boolean', 'x-danger': true, 'x-group': 'danger' },
++ price: { type: 'integer', 'x-unit': 'nano_usd' },
++ timeout: { type: 'integer', 'x-unit': 'ms' },
++ },
++};
++const DEFAULTS = { provider: 'p', base_url: 'https://a.example' };
++const classify = (k) => (/_key$/.test(k) ? 'secret' : undefined);
++const activeRow = async (db, ns) => await db.prepare("SELECT * FROM config_snapshots WHERE namespace = ? AND state = 'active'").get(ns);
++const storedKey = async (db, ns) => Buffer.from((await db.prepare('SELECT hmac_key FROM config_keys WHERE namespace = ?').get(ns)).hmac_key);
++const envelopeOf = async (db, ns, key) => JSON.parse((await activeRow(db, ns)).values_json)[key];
++const noSecret = (text, values, what) => {
++ for (const s of values) assert.ok(!String(text).includes(s), `${what} leaks a secret`);
++};
++
++(async () => {
++ const db = createDb({ pglite: true, log: { log() {}, warn() {} } });
++ await db.query(configSchema());
++
++ // ── KEKs: every bad shape is refused at startup, naming the variable and the key id, never a byte ──
++ const bad = async (opts, re) => {
++ let err;
++ await assert.rejects(createConfigStore(opts), (e) => { err = e; return re.test(e.message); });
++ return err;
++ };
++ let err = await bad({ db, service: 'live', namespace: 'live.kek.a', keks: { current: 'k1', keys: { k1: Buffer.alloc(31) } } }, /opts\.keks\.keys\.k1/);
++ assert.ok(!err.message.includes(b64(Buffer.alloc(31))), 'the error never carries the key');
++ await bad({ db, service: 'live', namespace: 'live.kek.b', keks: { current: 'k1', keys: { k1: 'a string' } } }, /opts\.keks\.keys\.k1/);
++ await bad({ db, service: 'live', namespace: 'live.kek.c', keks: { current: 'k9', keys: { k1: K1 } } }, /opts\.keks\.current/);
++ await bad({ db, service: 'live', namespace: 'live.kek.d', keks: { keys: { k1: K1 } } }, /opts\.keks\.current/);
++ await bad({ db, service: 'live', namespace: 'live.kek.e', env: { OV_CONFIG_KEKS: `k1:${b64(Buffer.alloc(31))}`, OV_CONFIG_KEK_CURRENT: 'k1' } }, /OV_CONFIG_KEKS/);
++ await bad({ db, service: 'live', namespace: 'live.kek.f', env: { OV_CONFIG_KEKS: `k1:${b64(K1)}` } }, /OV_CONFIG_KEK_CURRENT/);
++ await bad({ db, service: 'live', namespace: 'live.kek.g', env: { OV_CONFIG_KEKS: `k1:${b64(K1)},bad`, OV_CONFIG_KEK_CURRENT: 'k1' } }, /OV_CONFIG_KEKS/);
++ await bad({ db, service: 'live', namespace: 'live.kek.h', env: { OV_CONFIG_KEKS: `k1:${b64(K1)},k1:${b64(K2)}`, OV_CONFIG_KEK_CURRENT: 'k1' } }, /twice/);
++ const envStore = await createConfigStore({ db, service: 'live', namespace: 'live.kek.env', env: { OV_CONFIG_KEKS: `k1:${b64(K1)}`, OV_CONFIG_KEK_CURRENT: 'k1' }, log });
++ assert.strictEqual(envStore.sealed, true);
++ assert.strictEqual(envStore.kek, 'k1');
++
++ // ── Sealed storage: values_json holds envelopes, get() opens them, nothing else shows the value ──
++ const activations = [];
++ const changes = [];
++ let failNext = null;
++ const live = await createConfigStore({
++ db, service: 'live', namespace: 'live.sealed', schema: SCHEMA, classify, defaults: DEFAULTS, keks: keks1, log,
++ onActivate: async (values) => { activations.push(values); if (failNext) { const e = failNext; failNext = null; throw e; } },
++ onChange: async (e) => changes.push(e),
++ });
++ assert.strictEqual(live.sealed, true);
++ await live.apply({ provider_key: SECRET, api_key: SECRET2, price: 1000, timeout: 250 }, { actor: USER, reason: 'keys' });
++ assert.strictEqual(live.get('provider_key'), SECRET, 'the service gets the plaintext');
++ assert.strictEqual(live.get('api_key'), SECRET2);
++ assert.strictEqual(activations.at(-1).provider_key, SECRET, 'onActivate gets the plaintext');
++ const sealedRow = await activeRow(db, 'live.sealed');
++ const sealedValues = JSON.parse(sealedRow.values_json);
++ for (const k of ['provider_key', 'api_key']) {
++ assert.match(sealedValues[k], /^v1:k1:[0-9a-f]{64}:/, `${k} is an envelope`);
++ assert.ok(!sealedValues[k].includes(SECRET) && !sealedValues[k].includes(SECRET2));
++ }
++ noSecret(sealedRow.values_json, [SECRET, SECRET2], 'values_json');
++ noSecret(JSON.stringify(await live.snapshot(live.revision())), [SECRET, SECRET2], 'snapshot');
++ noSecret(JSON.stringify(await live.history({ limit: 200 })), [SECRET, SECRET2], 'history');
++ noSecret(JSON.stringify(await live.lastKnownGood()), [SECRET, SECRET2], 'last-known-good');
++ noSecret(JSON.stringify(await live.summary()), [SECRET, SECRET2], 'summary');
++ const summary = await live.summary();
++ assert.strictEqual(summary.sealed, true);
++ assert.strictEqual(summary.kek, 'k1');
++ assert.deepStrictEqual(summary.values.provider_key, { redacted: true, fingerprint: summary.secrets.provider_key.fingerprint });
++ assert.strictEqual(summary.secrets.provider_key.hint, SECRET.slice(-4), 'the four-character hint');
++ assert.strictEqual(summary.secrets.provider_key.bound_to, 'https://a.example', 'bound to the base URL');
++ assert.deepStrictEqual(summary.secrets.provider_key.set_by, USER);
++ assert.match(summary.secrets.provider_key.set_at, /^\d{4}-\d\d-\d\dT/);
++ assert.strictEqual(summary.secrets.api_key.bound_to, null);
++ // The schema is in the summary, with the owner annotations intact.
++ assert.strictEqual(summary.schema.properties.provider.title, 'Provider');
++ assert.strictEqual(summary.schema.properties.provider['x-group'], 'providers');
++ assert.strictEqual(summary.schema.properties.danger_switch['x-danger'], true);
++ assert.strictEqual(summary.schema.properties.provider_key['x-bind'], 'base_url');
++ assert.strictEqual(summary.schema.properties.price['x-unit'], 'nano_usd');
++ // A database dump (config_keys plus snapshots) has no key that reproduces a fingerprint.
++ const dumpKey = await storedKey(db, 'live.sealed');
++ assert.ok(dumpKey.toString('utf8').startsWith('ovfp.v1.'), 'config_keys holds a KEK-wrapped key, not the fingerprint key');
++ assert.notStrictEqual(dumpKey.length, 32);
++ const unwrapped = openFingerprintKey(dumpKey, { service: 'live', namespace: 'live.sealed', keys: new Map([['k1', K1]]) });
++ assert.strictEqual(unwrapped.key.length, 32);
++ assert.ok(!dumpKey.equals(unwrapped.key));
++ assert.notStrictEqual(crypto.createHmac('sha256', dumpKey).update(JSON.stringify(SECRET)).digest('hex'), summary.secrets.api_key.fingerprint);
++ for (const r of await db.prepare('SELECT namespace, hmac_key FROM config_keys').all()) {
++ const b = Buffer.from(r.hmac_key);
++ assert.ok(!b.includes(unwrapped.key), `${r.namespace} config_keys holds the raw fingerprint key`);
++ if (b.length === 32) assert.notStrictEqual(crypto.createHmac('sha256', b).update(JSON.stringify(SECRET)).digest('hex'), summary.secrets.api_key.fingerprint, `${r.namespace} reproduces the marker`);
++ }
++ for (const r of await db.prepare('SELECT namespace, values_json FROM config_snapshots').all()) {
++ assert.ok(!Buffer.from(r.values_json).includes(unwrapped.key), `${r.namespace} values_json holds the raw fingerprint key`);
++ }
++ // Restart: the same KEK reads the same values and the same markers.
++ const restarted = await createConfigStore({ db, service: 'live', namespace: 'live.sealed', schema: SCHEMA, classify, defaults: DEFAULTS, keks: keks1, log });
++ assert.strictEqual(restarted.get('provider_key'), SECRET);
++ assert.deepStrictEqual((await restarted.summary()).values, summary.values, 'stable fingerprints');
++ summary.values.provider = 'MUTATED';
++ summary.values.base_url = 'https://mutated.example';
++ assert.strictEqual(live.get('provider'), 'p', 'a summary copy cannot change the store');
++ assert.strictEqual(live.get('base_url'), 'https://a.example');
++
++ // ── x-bind: a changed bound value locks the secret; entering it again binds it to the new value; rollback restores both ──
++ const beforeBind = live.revision();
++ await live.apply({ base_url: 'https://b.example' }, { merge: true, actor: USER, reason: 'new host' });
++ assert.strictEqual(live.get('base_url'), 'https://b.example');
++ assert.strictEqual(live.get('provider_key'), undefined, 'the secret no longer opens');
++ assert.strictEqual(live.get('api_key'), SECRET2, 'an unbound secret is untouched');
++ const lockedSummary = await live.summary();
++ assert.ok(lockedSummary.values.provider_key.redacted, 'it is still shown as set');
++ assert.strictEqual(lockedSummary.secrets.provider_key.bound_to, 'https://a.example', 'the marker names what it was sealed to');
++ noSecret(JSON.stringify(lockedSummary), [SECRET], 'locked summary');
++ await live.setSecret('provider_key', SECRET, { actor: USER, reason: 'for the new host' });
++ assert.strictEqual(live.get('provider_key'), SECRET, 'entering it again seals it to the new bind');
++ assert.strictEqual((await live.summary()).secrets.provider_key.bound_to, 'https://b.example');
++ const boundAgain = live.revision();
++ const envelopeAtBind = await envelopeOf(db, 'live.sealed', 'provider_key');
++ await live.apply({ base_url: 'https://c.example' }, { merge: true, actor: USER });
++ assert.strictEqual(live.get('provider_key'), undefined);
++ const rolled = await live.rollback({ to: boundAgain, actor: USER, reason: 'back to b' });
++ assert.strictEqual(rolled.copied_from, boundAgain, 'the rollback copies the revision that had both b and the key');
++ assert.strictEqual(live.get('base_url'), 'https://b.example');
++ assert.strictEqual(live.get('provider_key'), SECRET, 'rolling back the bind restores the sealed value');
++ assert.strictEqual(await envelopeOf(db, 'live.sealed', 'provider_key'), envelopeAtBind, 'the rollback restored the envelope itself');
++
++ // A new secret may not bind to a secret-class key, and x-bind must name a known, unbound key.
++ await assert.rejects(createConfigStore({
++ db, service: 'live', namespace: 'live.bindsecret', schema: { type: 'object', properties: { a_key: { type: 'string' }, b_key: { type: 'string', 'x-bind': 'a_key' } } }, classify, keks: keks1, log,
++ }), /x-bind/);
++ await assert.rejects(createConfigStore({ db, service: 'live', namespace: 'live.bindunknown', schema: { type: 'object', properties: { a_key: { type: 'string', 'x-bind': 'nope' } } }, classify, keks: keks1, log }), /unknown key/);
++ await assert.rejects(createConfigStore({ db, service: 'live', namespace: 'live.bindself', schema: { type: 'object', properties: { a_key: { type: 'string', 'x-bind': 'a_key' } } }, classify, keks: keks1, log }), /itself/);
++
++ // A copied envelope does not open under another key (the AAD names the key), and never becomes a value.
++ const providerEnvelope = await envelopeOf(db, 'live.sealed', 'provider_key');
++ await live.setSecret('api_key', providerEnvelope, { actor: USER, reason: 'a copy' });
++ assert.strictEqual(live.get('api_key'), undefined, 'a copied envelope is locked');
++ assert.strictEqual(JSON.parse((await activeRow(db, 'live.sealed')).values_json).api_key, providerEnvelope, 'it is stored as the envelope, not as the other value');
++ await live.setSecret('api_key', SECRET2, { actor: USER, reason: 'entered again' });
++ assert.strictEqual(live.get('api_key'), SECRET2);
++
++ // ── Write-only secret routes on Express ──
++ const app = express();
++ app.use(express.json());
++ app.use((req, _res, next) => {
++ const who = req.headers['x-test-user'];
++ if (who === 'owner') req.user = { id: 2, role: 'admin', owner: true, subject_id: USER2.id };
++ else if (who === 'admin') req.user = { id: 1, role: 'admin', subject_id: USER.id };
++ else if (who === 'nosubject') req.user = { id: 3, role: 'admin' };
++ next();
++ });
++ const requireAdmin = (req, res, next) => (req.user && req.user.role === 'admin' ? next() : res.status(403).json({ error: 'Admin only' }));
++ const authorize = (req, { action, keys }) => action === 'read' || (req.user && req.user.owner) || !keys.some((k) => live.classify(k, undefined) === 'secret');
++ let freshOk = false;
++ const freshCalls = [];
++ const requireFresh = (req, ctx) => { freshCalls.push(ctx); return freshOk; };
++ adminRoutes([live], { requireAdmin, authorize, requireFresh, actor: (req) => ({ type: 'user', id: req.user.subject_id }) }).mount(app);
++ const server = app.listen(0);
++ await new Promise((r) => server.once('listening', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const texts = [];
++ async function call(method, path, { user = 'admin', body } = {}) {
++ const res = await fetch(base + path, { method, headers: { 'x-test-user': user, ...(body !== undefined ? { 'content-type': 'application/json' } : {}) }, body: body !== undefined ? JSON.stringify(body) : undefined });
++ const text = await res.text();
++ texts.push(text);
++ return { status: res.status, type: res.headers.get('content-type') || '', body: text ? JSON.parse(text) : null };
++ }
++ const beforeFresh = live.revision();
++ let res = await call('PUT', '/api/admin/config/live.sealed/secrets/api_key', { user: 'owner', body: { value: SECRET, reason: 'fresh?' } });
++ assert.strictEqual(res.status, 403);
++ assert.strictEqual(res.body.code, 'config.fresh_required');
++ assert.strictEqual(live.revision(), beforeFresh, 'a refused fresh check writes nothing');
++ assert.deepStrictEqual(freshCalls.at(-1), { namespace: 'live.sealed', keys: ['api_key'] });
++ freshOk = true;
++ res = await call('PUT', '/api/admin/config/live.sealed/secrets/api_key', { user: 'owner', body: { value: SECRET, reason: 'owner key' } });
++ assert.strictEqual(res.status, 200, JSON.stringify(res.body));
++ assert.deepStrictEqual(Object.keys(res.body).sort(), ['bound_to', 'fingerprint', 'hint', 'redacted', 'set_at', 'set_by']);
++ assert.ok(!JSON.stringify(res.body).includes(SECRET));
++ assert.strictEqual(res.body.set_by.id, USER2.id);
++ assert.strictEqual(live.get('api_key'), SECRET);
++ assert.deepStrictEqual(changes.at(-1).keys, ['api_key'], 'a secret write raises onChange');
++ assert.strictEqual(JSON.parse((await activeRow(db, 'live.sealed')).values_json).api_key.startsWith('v1:'), true);
++ // A normal POST is not a secret write path.
++ res = await call('POST', '/api/admin/config/live.sealed', { user: 'owner', body: { values: { api_key: SECRET2 }, merge: true } });
++ assert.strictEqual(res.status, 422);
++ assert.strictEqual(res.body.code, 'config.secret_write_only');
++ // Even the value it already holds may not be carried through the normal route.
++ res = await call('POST', '/api/admin/config/live.sealed', { user: 'owner', body: { values: { api_key: SECRET }, merge: true } });
++ assert.strictEqual(res.status, 422);
++ assert.strictEqual(res.body.code, 'config.secret_write_only');
++ res = await call('POST', '/api/admin/config/live.sealed', { user: 'owner', body: { values: {}, merge: true, unset: ['api_key'] } });
++ assert.strictEqual(res.status, 422, 'unsetting a secret through the normal route is write-only too');
++ assert.strictEqual(res.body.code, 'config.secret_write_only');
++ assert.strictEqual(live.get('api_key'), SECRET);
++ // A marker round trip is allowed: it carries no value.
++ res = await call('POST', '/api/admin/config/live.sealed', { user: 'owner', body: { values: { api_key: (await live.summary()).values.api_key }, merge: true, reason: 'form' } });
++ assert.strictEqual(res.status, 200, JSON.stringify(res.body));
++ assert.strictEqual(live.get('api_key'), SECRET);
++ // Only a secret-class key can be written through the secret route.
++ res = await call('PUT', '/api/admin/config/live.sealed/secrets/provider', { user: 'owner', body: { value: 'x', reason: 'nope' } });
++ assert.strictEqual(res.status, 422);
++ assert.strictEqual(res.body.code, 'config.not_secret');
++ // A missing value is a bad request, never an empty secret.
++ res = await call('PUT', '/api/admin/config/live.sealed/secrets/api_key', { user: 'owner', body: { reason: 'missing' } });
++ assert.strictEqual(res.status, 400);
++ assert.strictEqual(res.body.code, 'config.bad_request');
++ // An admin who does not own the secret is refused before write-only/fresh checks.
++ res = await call('PUT', '/api/admin/config/live.sealed/secrets/api_key', { user: 'admin', body: { value: SECRET2, reason: 'no' } });
++ assert.strictEqual(res.status, 403);
++ assert.strictEqual(res.body.code, 'config.forbidden');
++ // DELETE needs a fresh sign-in too, then writes a new revision without the key and answers the empty marker.
++ freshOk = false;
++ res = await call('DELETE', '/api/admin/config/live.sealed/secrets/api_key', { user: 'owner', body: { reason: 'gone' } });
++ assert.strictEqual(res.status, 403);
++ assert.strictEqual(res.body.code, 'config.fresh_required');
++ assert.strictEqual(live.get('api_key'), SECRET, 'a refused delete changes nothing');
++ freshOk = true;
++ res = await call('DELETE', '/api/admin/config/live.sealed/secrets/api_key', { user: 'owner', body: { reason: 'gone' } });
++ assert.strictEqual(res.status, 200, JSON.stringify(res.body));
++ assert.deepStrictEqual(Object.keys(res.body).sort(), ['bound_to', 'fingerprint', 'hint', 'redacted', 'set_at', 'set_by']);
++ assert.strictEqual(res.body.fingerprint, null);
++ assert.strictEqual(live.get('api_key'), undefined);
++ assert.ok(!JSON.stringify(JSON.parse((await activeRow(db, 'live.sealed')).values_json)).includes('api_key'));
++ res = await call('DELETE', '/api/admin/config/live.sealed/secrets/api_key', { user: 'owner', body: { reason: 'again' } });
++ assert.strictEqual(res.status, 404);
++ assert.strictEqual(res.body.code, 'config.secret_not_set');
++ // An x-danger key needs a fresh sign-in through a normal POST, and no secret is involved.
++ freshOk = false;
++ res = await call('POST', '/api/admin/config/live.sealed', { user: 'owner', body: { values: { danger_switch: true }, merge: true, reason: 'money' } });
++ assert.strictEqual(res.status, 403);
++ assert.strictEqual(res.body.code, 'config.fresh_required');
++ assert.deepStrictEqual(freshCalls.at(-1), { namespace: 'live.sealed', keys: ['danger_switch'] });
++ freshOk = true;
++ res = await call('POST', '/api/admin/config/live.sealed', { user: 'owner', body: { values: { danger_switch: true }, merge: true, reason: 'money' } });
++ assert.strictEqual(res.status, 200);
++ assert.strictEqual(live.get('danger_switch'), true);
++ noSecret(texts.join('\n'), [SECRET, SECRET2], 'responses');
++ assert.ok(texts.every((x) => !x.includes(SECRET)));
++ server.closeAllConnections();
++ server.close();
++
++ // A plain http server dispatches the two new routes too.
++ const plain = adminRoutes([live], { requireAdmin: (_q, _s, nx) => nx(), requireFresh: () => true, actor: () => USER });
++ const plainServer = require('http').createServer((req, res) => plain.handle(req, res));
++ plainServer.listen(0);
++ await new Promise((r) => plainServer.once('listening', r));
++ const pbase = `http://127.0.0.1:${plainServer.address().port}`;
++ const pr = await fetch(`${pbase}/api/admin/config/live.sealed/secrets/api_key`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value: SECRET, reason: 'plain' }) });
++ assert.strictEqual(pr.status, 200, await pr.clone().text());
++ assert.strictEqual(live.get('api_key'), SECRET);
++ const pdel = await fetch(`${pbase}/api/admin/config/live.sealed/secrets/api_key`, { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: '{"reason":"plain"}' });
++ assert.strictEqual(pdel.status, 200);
++ plainServer.close();
++
++ // ── x-env pins: the env's value is effective and read-only in the store and the routes ──
++ const pinSchema = {
++ type: 'object', additionalProperties: false,
++ properties: {
++ base_url: { type: 'string', 'x-env': 'OV_TEST_BASE_URL' },
++ max: { type: 'integer', 'x-env': 'OV_TEST_MAX' },
++ pinned_key: { type: 'string', 'x-env': 'OV_TEST_PINNED_KEY' },
++ free: { type: 'string' },
++ },
++ };
++ const pinEnv = { OV_TEST_BASE_URL: 'https://pinned.example', OV_TEST_MAX: '42', OV_TEST_PINNED_KEY: ENV_SECRET };
++ const pinned = await createConfigStore({ db, service: 'live', namespace: 'live.pins', schema: pinSchema, classify, env: pinEnv, defaults: { free: 'f' }, keks: keks1, log });
++ assert.strictEqual(pinned.get('base_url'), 'https://pinned.example');
++ assert.strictEqual(pinned.get('max'), 42, 'an x-env integer is typed');
++ assert.strictEqual(pinned.get('pinned_key'), ENV_SECRET, 'a pinned secret is effective');
++ const pinSummary = await pinned.summary();
++ assert.deepStrictEqual(pinSummary.pins, {
++ base_url: { env: 'OV_TEST_BASE_URL', pinned: true },
++ max: { env: 'OV_TEST_MAX', pinned: true },
++ pinned_key: { env: 'OV_TEST_PINNED_KEY', pinned: true },
++ });
++ assert.strictEqual(pinSummary.values.base_url, 'https://pinned.example');
++ assert.deepStrictEqual(pinSummary.values.pinned_key, { redacted: true, fingerprint: pinSummary.secrets.pinned_key.fingerprint });
++ noSecret(JSON.stringify(pinSummary), [ENV_SECRET], 'pinned summary');
++ await assert.rejects(pinned.apply({ base_url: 'https://elsewhere.example' }, { merge: true, actor: USER }), (e) => e.code === 'config.pinned_in_env' && e.status === 409);
++ await assert.rejects(pinned.apply({}, { merge: true, unset: ['max'], actor: USER }), (e) => e.code === 'config.pinned_in_env' && e.status === 409);
++ await assert.rejects(pinned.setSecret('pinned_key', SECRET, { actor: USER }), (e) => e.code === 'config.pinned_in_env' && e.status === 409);
++ await assert.rejects(pinned.deleteSecret('pinned_key', { actor: USER }), (e) => e.code === 'config.pinned_in_env' && e.status === 409);
++ const pinApp = express();
++ pinApp.use(express.json());
++ pinApp.use((req, _res, next) => { req.user = { role: 'admin', owner: true, subject_id: USER.id }; next(); });
++ adminRoutes([pinned], { requireAdmin: (_q, _s, nx) => nx(), requireFresh: () => true }).mount(pinApp);
++ const pinServer = pinApp.listen(0);
++ await new Promise((r) => pinServer.once('listening', r));
++ const pinBase = `http://127.0.0.1:${pinServer.address().port}`;
++ let pres = await fetch(`${pinBase}/api/admin/config/live.pins`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ values: { base_url: 'https://x.example' }, merge: true }) });
++ assert.strictEqual(pres.status, 409);
++ assert.strictEqual((await pres.json()).code, 'config.pinned_in_env');
++ pres = await fetch(`${pinBase}/api/admin/config/live.pins/secrets/pinned_key`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value: SECRET }) });
++ assert.strictEqual(pres.status, 409);
++ assert.strictEqual((await pres.json()).code, 'config.pinned_in_env');
++ pinServer.close();
++ // A declared env name that is not set pins nothing and stays writable.
++ const unpinned = await createConfigStore({ db, service: 'live', namespace: 'live.unpinned', schema: pinSchema, classify, env: {}, defaults: {}, keks: keks1, log });
++ assert.deepStrictEqual((await unpinned.summary()).pins, {});
++ await unpinned.apply({ base_url: 'https://writable.example' }, { merge: true, actor: USER });
++ assert.strictEqual(unpinned.get('base_url'), 'https://writable.example');
++
++ // ── The schema in the summary never carries a secret default ──
++ const leaky = {
++ type: 'object', additionalProperties: false,
++ properties: {
++ api_key: { type: 'string', default: 'LEAK-ME-DEFAULT-1', enum: ['LEAK-ME-ENUM-2'], const: 'LEAK-ME-CONST-3', examples: ['LEAK-ME-EXAMPLE-4'], title: 'Key' },
++ provider: { type: 'string', default: 'p' },
++ },
++ };
++ const leak = await createConfigStore({ db, service: 'live', namespace: 'live.schema', schema: leaky, classify, defaults: {}, keks: keks1, log });
++ const leakSummary = await leak.summary();
++ for (const x of ['LEAK-ME-DEFAULT-1', 'LEAK-ME-ENUM-2', 'LEAK-ME-CONST-3', 'LEAK-ME-EXAMPLE-4']) assert.ok(!JSON.stringify(leakSummary).includes(x), `${x} is stripped from the summary`);
++ assert.strictEqual(leakSummary.schema.properties.provider.default, 'p', 'a public default stays');
++ // A default secret cannot leak through an invalid_defaults error either.
++ const defaultSecret = 'DEFAULT-MADE-UP-SECRET-5';
++ let defaultErr;
++ await assert.rejects(createConfigStore({
++ db, service: 'live', namespace: 'live.bad_default_secret', schema: { type: 'object', properties: { api_key: { type: 'string', maxLength: 5 } } }, classify, defaults: { api_key: defaultSecret }, keks: keks1, log,
++ }), (e) => { defaultErr = e; return e.code === 'config.invalid_defaults'; });
++ assert.ok(!defaultErr.message.includes(defaultSecret), 'the invalid-defaults error is scrubbed');
++ // A value-dependent classifier can still make an x-bind target secret after creation: never show that value.
++ const valueClassify = (k, v) => (k === 'base_secret' && v === 'BIND-MADE-UP-SECRET-6' ? 'secret' : (k === 'bound_key' ? 'secret' : undefined));
++ const bindLeak = await createConfigStore({
++ db, service: 'live', namespace: 'live.bindleak', schema: { type: 'object', additionalProperties: false, properties: { base_secret: { type: 'string' }, bound_key: { type: 'string', 'x-bind': 'base_secret' } } }, classify: valueClassify, env: {}, keks: false, log,
++ });
++ await bindLeak.apply({ base_secret: 'BIND-MADE-UP-SECRET-6', bound_key: 'BOUND-VALUE' }, { actor: USER });
++ const bindLeakSummary = await bindLeak.summary();
++ assert.ok(!JSON.stringify(bindLeakSummary).includes('BIND-MADE-UP-SECRET-6'), 'a secret bind target is not echoed in bound_to');
++ assert.strictEqual(bindLeakSummary.secrets.bound_key.bound_to, null);
++ const enumSecret = await createConfigStore({ db, service: 'live', namespace: 'live.enumsecret', schema: { type: 'object', additionalProperties: false, properties: { api_key: { type: 'string', enum: ['ENUM-MADE-UP-SECRET-7'] } } }, classify, defaults: {}, keks: keks1, log });
++ await assert.rejects(enumSecret.setSecret('api_key', 'another value', { actor: USER }), (e) => e.code === 'config.invalid' && !JSON.stringify(e).includes('ENUM-MADE-UP-SECRET-7'));
++ await assert.rejects(createConfigStore({ db, service: 'live', namespace: 'live.badunit', schema: { type: 'object', properties: { x: { type: 'integer', 'x-unit': 'seconds' } } }, keks: keks1, log }), /x-unit/);
++ await assert.rejects(createConfigStore({ db, service: 'live', namespace: 'live.badenv', schema: { type: 'object', properties: { x: { type: 'string', 'x-env': 'lower-case' } } }, keks: keks1, log }), /x-env/);
++ await assert.rejects(createConfigStore({ db, service: 'live', namespace: 'live.baddanger', schema: { type: 'object', properties: { x: { type: 'boolean', 'x-danger': 'yes' } } }, keks: keks1, log }), /x-danger/);
++
++ // ── No KEK: exactly yesterday's storage and redaction, flagged sealed: false ──
++ const plainStore = await createConfigStore({ db, service: 'live', namespace: 'live.plain', schema: SCHEMA, classify, defaults: DEFAULTS, env: {}, log });
++ assert.strictEqual(plainStore.sealed, false);
++ assert.strictEqual(plainStore.kek, null);
++ await plainStore.apply({ api_key: SECRET }, { merge: true, actor: USER });
++ const plainRow = await activeRow(db, 'live.plain');
++ assert.ok(JSON.parse(plainRow.values_json).api_key === SECRET, 'without a KEK the row keeps the value as it did');
++ assert.deepStrictEqual((await plainStore.summary()).values.api_key, { redacted: true, fingerprint: (await plainStore.summary()).secrets.api_key.fingerprint });
++ assert.strictEqual((await plainStore.summary()).sealed, false);
++ await assert.rejects(plainStore.reseal(), (e) => e.code === 'config.no_kek' && e.status === 409);
++ noSecret(JSON.stringify(await plainStore.history({ limit: 200 })), [SECRET], 'plain history');
++
++ // ── reseal(): plaintext rows move under the current KEK, old KEKs rotate, no value changes ──
++ const beforeReseal = await plainStore.summary();
++ const plainSnap = await plainStore.snapshot(plainStore.revision());
++ const plainKey = await storedKey(db, 'live.plain');
++ assert.strictEqual(plainKey.length, 32, 'without a KEK config_keys still holds the plain 32-byte key');
++ assert.strictEqual(beforeReseal.secrets.api_key.fingerprint, crypto.createHmac('sha256', plainKey).update(JSON.stringify(SECRET)).digest('hex'), "no KEK keeps today's marker");
++ const migrated = await createConfigStore({ db, service: 'live', namespace: 'live.plain', schema: SCHEMA, classify, defaults: DEFAULTS, env: {}, keks: keks1, log });
++ assert.ok((await storedKey(db, 'live.plain')).toString('utf8').startsWith('ovfp.v1.k1'), 'enabling a KEK wraps the same fingerprint key');
++ assert.strictEqual(migrated.get('api_key'), SECRET, 'reading the open plaintext is unchanged');
++ const report1 = await migrated.reseal();
++ assert.strictEqual(report1.plaintext, 1);
++ assert.strictEqual(report1.resealed, 0);
++ const migratedRow = await activeRow(db, 'live.plain');
++ assert.ok(!migratedRow.values_json.includes(SECRET), 'reseal put the plaintext away');
++ assert.match(JSON.parse(migratedRow.values_json).api_key, /^v1:k1:/);
++ assert.strictEqual(migrated.get('api_key'), SECRET, 'the same value after reseal');
++ assert.deepStrictEqual((await migrated.summary()).values.api_key, beforeReseal.values.api_key, 'the marker fingerprint survives reseal');
++ assert.strictEqual((await migrated.snapshot(migrated.revision())).checksum, plainSnap.checksum, 'the checksum survives reseal');
++ // Rotation: start with both KEKs once, which rewraps the fingerprint key under k2 without moving it;
++ // the k1 envelope still needs resealing before k1 can go.
++ const rotating = await createConfigStore({ db, service: 'live', namespace: 'live.plain', schema: SCHEMA, classify, defaults: DEFAULTS, env: {}, keks: keks12, log });
++ assert.strictEqual(rotating.get('api_key'), SECRET, 'two KEKs open the old one');
++ assert.ok((await storedKey(db, 'live.plain')).toString('utf8').startsWith('ovfp.v1.k2'), 'the fingerprint key wrapper follows the current KEK');
++ const fpBeforeRotation = (await rotating.summary()).secrets.api_key.fingerprint;
++ const snapBeforeRotation = await rotating.snapshot(rotating.revision());
++ const k2Only = await createConfigStore({ db, service: 'live', namespace: 'live.plain', schema: SCHEMA, classify, defaults: DEFAULTS, env: {}, keks: { current: 'k2', keys: { k2: K2 } }, log });
++ assert.strictEqual(k2Only.get('api_key'), undefined, 'a k1 envelope does not open with k2 alone');
++ const report2 = await rotating.reseal();
++ assert.strictEqual(report2.resealed, 1);
++ assert.match(JSON.parse((await activeRow(db, 'live.plain')).values_json).api_key, /^v1:k2:/);
++ const k2After = await createConfigStore({ db, service: 'live', namespace: 'live.plain', schema: SCHEMA, classify, defaults: DEFAULTS, env: {}, keks: { current: 'k2', keys: { k2: K2 } }, log });
++ assert.strictEqual(k2After.get('api_key'), SECRET, 'after reseal the current KEK alone reads it');
++ assert.strictEqual((await k2After.summary()).secrets.api_key.fingerprint, fpBeforeRotation, 'the marker fingerprint survives the k1 -> k2 rotation');
++ assert.strictEqual((await k2After.snapshot(k2After.revision())).checksum, snapBeforeRotation.checksum, 'and so does the snapshot checksum');
++ await assert.rejects(createConfigStore({ db, service: 'live', namespace: 'live.plain', schema: SCHEMA, classify, defaults: DEFAULTS, env: {}, keks: false, log }), /sealed under a KEK/);
++
++ // reseal() recomputes an envelope's fingerprint rather than trusting its header.
++ const refp = await createConfigStore({ db, service: 'live', namespace: 'live.refp', schema: SCHEMA, classify, defaults: DEFAULTS, env: {}, keks: keks1, log });
++ await refp.apply({ api_key: SECRET }, { merge: true, actor: USER });
++ const refpRow = await activeRow(db, 'live.refp');
++ const refpValues = JSON.parse(refpRow.values_json);
++ const refpParts = refpValues.api_key.split(':');
++ const realFp = refpParts[2];
++ refpParts[2] = '0'.repeat(64);
++ await db.prepare('UPDATE config_snapshots SET values_json = ? WHERE namespace = ? AND revision = ?').run(JSON.stringify({ ...refpValues, api_key: refpParts.join(':') }), 'live.refp', refpRow.revision);
++ const refpRotated = await createConfigStore({ db, service: 'live', namespace: 'live.refp', schema: SCHEMA, classify, defaults: DEFAULTS, env: {}, keks: keks12, log });
++ assert.strictEqual(refpRotated.get('api_key'), SECRET);
++ const refpReport = await refpRotated.reseal();
++ assert.strictEqual(refpReport.resealed, 1);
++ const refpAfter = JSON.parse((await activeRow(db, 'live.refp')).values_json).api_key;
++ assert.strictEqual(refpAfter.split(':')[2], realFp, 'reseal recomputed the fingerprint with the stable key');
++ assert.strictEqual((await refpRotated.summary()).secrets.api_key.fingerprint, realFp);
++ // The same check for an envelope already under the current KEK: a fingerprint that does not match is repaired.
++ const refpVals2 = JSON.parse((await activeRow(db, 'live.refp')).values_json);
++ const refpParts2 = refpVals2.api_key.split(':');
++ refpParts2[2] = 'f'.repeat(64);
++ await db.prepare('UPDATE config_snapshots SET values_json = ? WHERE namespace = ? AND revision = ?').run(JSON.stringify({ ...refpVals2, api_key: refpParts2.join(':') }), 'live.refp', (await activeRow(db, 'live.refp')).revision);
++ const refpReport2 = await refpRotated.reseal();
++ assert.strictEqual(refpReport2.resealed, 1);
++ assert.strictEqual(JSON.parse((await activeRow(db, 'live.refp')).values_json).api_key.split(':')[2], realFp);
++ assert.strictEqual((await k2After.history({ limit: 200 })).filter((s) => s.state !== 'rejected').every((s) => !JSON.stringify(s).includes(SECRET)), true);
++ // A KEK that is missing from the environment leaves its rows locked and named in the report.
++ const orphanRow = await activeRow(db, 'live.plain');
++ await db.prepare("UPDATE config_snapshots SET values_json = ? WHERE namespace = 'live.plain' AND revision = ?")
++ .run(JSON.stringify({ ...JSON.parse(orphanRow.values_json), api_key: (JSON.parse(orphanRow.values_json).api_key).replace(/^v1:k2:/, 'v1:k9:') }), orphanRow.revision);
++ const orphan = await createConfigStore({ db, service: 'live', namespace: 'live.plain', schema: SCHEMA, classify, defaults: DEFAULTS, env: {}, keks: { current: 'k2', keys: { k2: K2 } }, log });
++ assert.strictEqual(orphan.get('api_key'), undefined);
++ const report3 = await orphan.reseal();
++ assert.ok(report3.locked >= 1);
++ assert.ok(!JSON.stringify(report3).includes(SECRET));
++
++ // ── Hooks: onChange after every activation, never on a proposal, and a failure does not refuse activation ──
++ const hookChanges = [];
++ let alertDown = false;
++ const hooked = await createConfigStore({
++ db, service: 'live', namespace: 'live.hooks', schema: SCHEMA, classify, defaults: DEFAULTS, keks: keks1, log,
++ onChange: async (e) => { hookChanges.push(e); if (alertDown) throw new Error('alert down'); },
++ });
++ const hookRev = hooked.revision();
++ alertDown = true;
++ await hooked.apply({ timeout: 100 }, { merge: true, actor: USER, reason: 'make the alert fail' });
++ alertDown = false;
++ assert.strictEqual(hooked.get('timeout'), 100, 'onChange never refuses an activation');
++ assert.strictEqual(hookChanges.length, 1);
++ assert.deepStrictEqual(hookChanges[0].keys, ['timeout']);
++ assert.deepStrictEqual(hookChanges[0].actor, USER);
++ assert.strictEqual(hookChanges[0].revision, hookRev + 1);
++ assert.ok(logs.some((l) => l.includes('onChange failed') && l.includes('alert down')));
++ await hooked.propose({ timeout: 200 }, { merge: true, actor: USER });
++ assert.strictEqual(hookChanges.length, 1, 'a proposal is not an activation');
++ await hooked.rollback({ actor: USER });
++ assert.strictEqual(hookChanges.length, 2);
++ assert.ok(hookChanges[1].keys.includes('timeout'));
++
++ // ── No secret ever reaches the log, a stored error or a response ──
++ failNext = new Error(`provider said no to ${SECRET}`);
++ await assert.rejects(live.apply({ danger_switch: false, timeout: 300 }, { merge: true, actor: USER }), (e) => !e.message.includes(SECRET) && e.message.includes('[redacted]'));
++ const failedRow = await db.prepare("SELECT error FROM config_snapshots WHERE namespace = 'live.sealed' AND state = 'rejected' ORDER BY revision DESC LIMIT 1").get();
++ assert.ok(failedRow && failedRow.error && !failedRow.error.includes(SECRET), 'a stored error is scrubbed');
++ noSecret(logs.join('\n'), [SECRET, SECRET2, ENV_SECRET], 'the log');
++ const stored = await db.prepare('SELECT namespace, values_json FROM config_snapshots').all();
++ for (const r of stored) assert.ok(!r.values_json.includes(SECRET), `${r.namespace} values_json`);
++ for (const text of texts) noSecret(text, [SECRET, SECRET2], 'a route response');
++ noSecret(JSON.stringify(await live.summary()), [SECRET], 'the sealed summary after the failure');
++
++ console.log('config secrets: envelopes, x-bind, write-only routes, x-env pins, summary schema, requireFresh, onChange, reseal and rotation ok');
++})().catch((err) => { console.error(err); process.exit(1); });