openvibers/openvibe.shared Commits
4.110.0: sealed secrets, write-only secret routes and owner settings for openvibe-shared/config (#288)
Changes
@@@ -4,6 +4,49 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking 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@@@ -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 deployedconsumer 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` namesthe tag it pins.@@@ -268,6 +268,8 @@ const settings = await config.createConfigStore({defaults, // revision 1 of a new namespace, and under every revisionlegacy: async () => config.fromRows(await db.all('SELECT * FROM site_settings')), // revision 1 insteadonActivate: 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 revisioawait 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 noneawait 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-readon 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. Asnapshot'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 handleractor: (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 → 403basePath: '/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)@@@ -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)",@@@ -63,6 +63,197 @@ const isMarker = (v) => isPlainObject(v) && v.redacted === true && Object.keys(vconst 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 };@@@ -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 KEKcreated_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(),};@@@ -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);}@@@ -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",@@@ -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": {@@@ -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');nnew file mode 100644@@@ -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); });
@@ -4,6 +4,49 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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).
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
## 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
- **A select inside a modal dialog or a fullscreen player opens over it.** The list (menu or phone sheet) used to be
@@ -2,7 +2,7 @@
@@ -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.
> 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
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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -268,6 +268,8 @@ const settings = await config.createConfigStore({
@@ -268,6 +268,8 @@ const settings = await config.createConfigStore({
defaults, // revision 1 of a new namespace, and under every revision
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
legacy: async () => config.fromRows(await db.all('SELECT * FROM site_settings')), // revision 1 instead
onActivate: async (values, previous, { restoring }) => { /* apply it; throw to refuse */ },
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(),
keep: 50, log: console, now: () => new Date(),
});
});
settings.get(); // defaults overlaid with the active values: frozen, in memory
settings.get(); // defaults overlaid with the active values: frozen, in memory
@@ -280,6 +282,11 @@ await settings.rollback({ actor, reason, to }); // a new revisio
@@ -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.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.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.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
- **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
@@ -295,14 +302,54 @@ await settings.reload(); // another process changed it: re-read
on the last-known-good.
on the last-known-good.
- **Last-known-good** is the newest revision that activated successfully. Pruning keeps the newest
- **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.
`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:
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.
older markers stop round-tripping. A key classified secret later is redacted in older revisions too.
Errors name the rule, never the value.
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`,
- `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.
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], {
@@ -311,20 +358,24 @@ config.adminRoutes([settings, other], {
requireAdmin, // a middleware or a list; guards every handler
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)
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),
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',
basePath: '/api/admin/config',
}).mount(app); // or { router }, the handlers, or handle(req, res, next) on plain http
}).mount(app); // or { router }, the handlers, or handle(req, res, next) on plain http
```
```
| Route | Answer |
| 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` | one of them |
| `GET /api/admin/config/:namespace/history?limit&before` | `{ namespace, snapshots, next_before }`, newest first |
| `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` |
| `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
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)
### Releases: `/release.json`, open tabs and the mixed-version test (ADR-016, Track R)
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.109.2",
+ "version": "4.110.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"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)",
"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)",
@@ -63,6 +63,197 @@ const isMarker = (v) => isPlainObject(v) && v.redacted === true && Object.keys(v
@@ -63,6 +63,197 @@ const isMarker = (v) => isPlainObject(v) && v.redacted === true && Object.keys(v
const looksRedacted = (v) => isPlainObject(v) && has(v, 'redacted');
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));
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 ──────────────────────────────────
// ── A small JSON Schema subset ──────────────────────────────────
// type, enum, const, properties, required, additionalProperties, min/maxProperties, items,
// type, enum, const, properties, required, additionalProperties, min/maxProperties, items,
// min/maxItems, uniqueItems, min/maxLength, pattern, minimum, maximum, exclusiveMinimum/Maximum,
// min/maxItems, uniqueItems, min/maxLength, pattern, minimum, maximum, exclusiveMinimum/Maximum,
@@ -74,8 +265,21 @@ const KEYWORDS = new Set(['type', 'enum', 'const', 'properties', 'required', 'ad
@@ -74,8 +265,21 @@ const KEYWORDS = new Set(['type', 'enum', 'const', 'properties', 'required', 'ad
'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf']);
'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf']);
const ANNOTATIONS = new Set(['$schema', '$id', '$comment', 'title', 'description', 'default', 'examples', 'deprecated', 'readOnly', 'writeOnly', 'format']);
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 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();
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 = '#') {
function compileSchema(s, at = '#') {
if (typeof s === 'boolean') return;
if (typeof s === 'boolean') return;
if (!isPlainObject(s)) throw new TypeError(`config schema ${at}: must be an object or a boolean`);
if (!isPlainObject(s)) throw new TypeError(`config schema ${at}: must be an object or a boolean`);
@@ -83,12 +287,47 @@ function compileSchema(s, at = '#') {
@@ -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 (!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`);
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.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.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.additionalProperties !== undefined) compileSchema(s.additionalProperties, `${at}/additionalProperties`);
if (s.items !== undefined) compileSchema(s.items, `${at}/items`);
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) {
function typeOf(v) {
if (v === null) return 'null';
if (v === null) return 'null';
if (Array.isArray(v)) return 'array';
if (Array.isArray(v)) return 'array';
@@ -100,15 +339,16 @@ function isType(v, t) {
@@ -100,15 +339,16 @@ function isType(v, t) {
return typeOf(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 === true || s === undefined) return;
if (s === false) { errors.push({ path, message: 'is not allowed' }); return; }
if (s === false) { errors.push({ path, message: 'is not allowed' }); return; }
if (s.type !== undefined) {
if (s.type !== undefined) {
const types = [].concat(s.type);
const types = [].concat(s.type);
if (!types.some((t) => isType(v, t))) { errors.push({ path, message: `must be ${types.join(' or ')}` }); return; }
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 (s.const !== undefined && !same(s.const, v)) errors.push({ path, message: 'must equal the constant' });
if (typeof v === 'number') {
if (typeof v === 'number') {
if (s.minimum !== undefined && v < s.minimum) errors.push({ path, message: `must be >= ${s.minimum}` });
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) {
@@ -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.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.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.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)) {
if (isPlainObject(v)) {
const keys = Object.keys(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.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` });
if (s.maxProperties !== undefined && keys.length > s.maxProperties) errors.push({ path, message: `must have at most ${s.maxProperties} keys` });
for (const k of 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 === 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 } = {}) {
@@ -212,4 +452,5 @@ function fromRows(rows, { prefix = '', type = null } = {}) {
return out;
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 };
@@ -4,8 +4,15 @@
@@ -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
// 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
// seeded its active revision when it resolves). The tables come from
// the service's migrations: configSchema() is their DDL.
// 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 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. */
/** The DDL of config_snapshots and config_keys, for a service's migrations. */
function configSchema() {
function configSchema() {
@@ -15,9 +22,9 @@ function configSchema() {
@@ -15,9 +22,9 @@ function configSchema() {
service text COLLATE "C" NOT NULL,
service text COLLATE "C" NOT NULL,
previous_revision bigint,
previous_revision bigint,
state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),
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,
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_at text COLLATE "C" NOT NULL,
created_by text COLLATE "C" NOT NULL,
created_by text COLLATE "C" NOT NULL,
activated_at text COLLATE "C",
activated_at text COLLATE "C",
@@ -32,21 +39,23 @@ CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshot
@@ -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.
-- The fingerprint key of each namespace. Never served, never logged.
CREATE TABLE IF NOT EXISTS config_keys (
CREATE TABLE IF NOT EXISTS config_keys (
namespace text COLLATE "C" PRIMARY KEY,
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
created_at text COLLATE "C" NOT NULL
);`;
);`;
}
}
async function createPgConfigStore(opts = {}) {
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 (!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 (!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) {
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`);
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 (schema !== null) compileSchema(schema);
if (validate !== null && typeof validate !== 'function') throw new TypeError('createConfigStore: validate must be a function');
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 (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 (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');
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));
const keep = Math.max(1, Math.floor(opts.keep || 50));
@@ -57,6 +66,85 @@ async function createPgConfigStore(opts = {}) {
@@ -57,6 +66,85 @@ async function createPgConfigStore(opts = {}) {
const self = { type: 'service', id: service };
const self = { type: 'service', id: service };
const defaults = deepFreeze(clone(opts.defaults || {}));
const defaults = deepFreeze(clone(opts.defaults || {}));
const tag = `[config] ${namespace}`;
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()).
// config_snapshots and config_keys come from the service's migrations (configSchema()).
const q = {
const q = {
@@ -76,52 +164,124 @@ async function createPgConfigStore(opts = {}) {
@@ -76,52 +164,124 @@ async function createPgConfigStore(opts = {}) {
good: db.prepare('UPDATE config_snapshots SET good = 1 WHERE namespace = ? AND revision = ?'),
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 = ?"),
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 != ?"),
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
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) ──
// ── 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)
let hmacKey = null; // loaded by init(): made on first use, then read (a racing process keeps the first)
async function loadKey() {
async function loadKey() {
const read = db.prepare('SELECT hmac_key FROM config_keys WHERE namespace = ?');
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);
let row = await read.get(namespace);
if (!row) {
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);
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 fingerprint = (v) => crypto.createHmac('sha256', hmacKey).update(canonical(v)).digest('hex');
const markerFor = (v) => ({ redacted: true, fingerprint: fingerprint(v) });
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) {
function toSnapshot(row) {
@@ -148,11 +308,23 @@ async function createPgConfigStore(opts = {}) {
@@ -148,11 +308,23 @@ async function createPgConfigStore(opts = {}) {
}
}
// ── in-memory state ──
// ── 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) {
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);
const effectiveNow = () => (current ? current.effective : emptyEffective);
@@ -166,29 +338,33 @@ async function createPgConfigStore(opts = {}) {
@@ -166,29 +338,33 @@ async function createPgConfigStore(opts = {}) {
if (errors.length) return errors;
if (errors.length) return errors;
const size = Buffer.byteLength(canonical(values));
const size = Buffer.byteLength(canonical(values));
if (size > maxBytes) return [{ path: '', message: `is ${size} bytes; at most ${maxBytes}` }];
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) {
if (validate) {
let r;
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)');
if (r && typeof r.then === 'function') throw new TypeError('createConfigStore: validate must be synchronous (check asynchronously in onActivate)');
errors.push(...validateResult(r));
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) }));
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 });
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 = [] } = {}) {
function resolve(input, { merge = false, unset = [] } = {}) {
if (!isPlainObject(input)) throw invalid([{ path: '', message: 'must be an object' }]);
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' }]);
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 = [];
const errors = [];
for (const [k, v] of Object.entries(input)) {
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 (!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 (!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 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' });
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 = {}) {
@@ -197,6 +373,31 @@ async function createPgConfigStore(opts = {}) {
return base;
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) {
function changedKeys(before, after) {
const keys = new Set([...Object.keys(before), ...Object.keys(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();
return [...keys].filter((k) => has(before, k) !== has(after, k) || !same(before[k], after[k])).sort();
@@ -217,13 +418,15 @@ async function createPgConfigStore(opts = {}) {
@@ -217,13 +418,15 @@ async function createPgConfigStore(opts = {}) {
}
}
async function insertRow(values, { state, actor, reason, previous, copiedFrom = null, activatedAt = null, good = 0 }) {
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;
let row;
await tx(async () => {
await tx(async () => {
const revision = (await q.max.get(namespace)).n + 1;
const revision = (await q.max.get(namespace)).n + 1;
await q.insert.run({
await q.insert.run({
namespace, revision, service, previous_revision: previous, state,
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,
activated_at: activatedAt, activated_by: activatedAt ? JSON.stringify(actor) : null,
reason, copied_from: copiedFrom, good,
reason, copied_from: copiedFrom, good,
});
});
@@ -300,6 +503,12 @@ async function createPgConfigStore(opts = {}) {
@@ -300,6 +503,12 @@ async function createPgConfigStore(opts = {}) {
return run;
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 } = {}) {
async function activateNow(revision, { actor, rollingBack = false } = {}) {
const by = actorOf(actor);
const by = actorOf(actor);
const row = await q.row.get(namespace, revision);
const row = await q.row.get(namespace, revision);
@@ -330,7 +539,7 @@ async function createPgConfigStore(opts = {}) {
@@ -330,7 +539,7 @@ async function createPgConfigStore(opts = {}) {
try {
try {
await onActivate(next.effective, prev ? prev.effective : null, { namespace, revision, previous_revision: prev ? prev.revision : null, restoring: false });
await onActivate(next.effective, prev ? prev.effective : null, { namespace, revision, previous_revision: prev ? prev.revision : null, restoring: false });
} catch (err) {
} 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 tx(async () => {
await q.reject.run(message, namespace, revision);
await q.reject.run(message, namespace, revision);
if (prev) await q.setState.run('active', namespace, prev.revision);
if (prev) await q.setState.run('active', namespace, prev.revision);
@@ -341,13 +550,14 @@ async function createPgConfigStore(opts = {}) {
@@ -341,13 +550,14 @@ async function createPgConfigStore(opts = {}) {
try {
try {
await onActivate(prev.effective, next.effective, { namespace, revision: prev.revision, previous_revision: revision, restoring: true });
await onActivate(prev.effective, next.effective, { namespace, revision: prev.revision, previous_revision: revision, restoring: true });
} catch (again) {
} 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 });
throw new ConfigError('config.activation_failed', message, { status: 422, revision, cause: err });
}
}
await q.good.run(namespace, revision);
await q.good.run(namespace, revision);
}
}
+ await notifyChange(changed, by, revision);
await prune();
await prune();
log.info(`${tag}: revision ${revision} active${prev ? ` (was ${prev.revision})` : ''} by ${who(by)}; changed ${listKeys(changed)}`);
log.info(`${tag}: revision ${revision} active${prev ? ` (was ${prev.revision})` : ''} by ${who(by)}; changed ${listKeys(changed)}`);
return toSnapshot(await q.row.get(namespace, revision));
return toSnapshot(await q.row.get(namespace, revision));
@@ -361,7 +571,7 @@ async function createPgConfigStore(opts = {}) {
@@ -361,7 +571,7 @@ async function createPgConfigStore(opts = {}) {
if (errors.length) throw invalid(errors);
if (errors.length) throw invalid(errors);
const row = await insertRow(next, { state: 'proposed', actor, reason, previous: current ? current.revision : null });
const row = await insertRow(next, { state: 'proposed', actor, reason, previous: current ? current.revision : null });
await prune();
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);
return toSnapshot(row);
}
}
@@ -380,10 +590,124 @@ async function createPgConfigStore(opts = {}) {
@@ -380,10 +590,124 @@ async function createPgConfigStore(opts = {}) {
return rev;
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 = {
const store = {
service,
service,
namespace,
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]; },
get(key) { const e = effectiveNow(); return key === undefined ? e : e[key]; },
/** The active revision number, or null. */
/** The active revision number, or null. */
revision() { return current ? current.revision : null; },
revision() { return current ? current.revision : null; },
@@ -412,18 +736,92 @@ async function createPgConfigStore(opts = {}) {
@@ -412,18 +736,92 @@ async function createPgConfigStore(opts = {}) {
},
},
/** Keys whose effective value would change: for new values (+ merge/unset), or for a revision's. */
/** Keys whose effective value would change: for new values (+ merge/unset), or for a revision's. */
async changes(values, o = {}) {
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() {
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 {
return {
service, namespace,
service, namespace,
revision: current ? current.revision : null,
revision: current ? current.revision : null,
- values: redact(effective, classification),
+ values: shown,
classification,
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,
active: current ? await store.snapshot(current.revision) : null,
last_known_good: await store.lastKnownGood(),
last_known_good: await store.lastKnownGood(),
};
};
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
/** Configuration store on an openvibe-sdk/db handle. The store is ready when the promise resolves. */
/** 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 = {}) {
function createConfigStore(opts = {}) {
if (!opts.db || typeof opts.db.prepare !== 'function' || typeof opts.db.tx !== 'function') {
if (!opts.db || typeof opts.db.prepare !== 'function' || typeof opts.db.tx !== 'function') {
@@ -55,17 +55,23 @@ function readBody(req, limit = 256 * 1024) {
@@ -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)
* 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:
* 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 = {}) {
function adminRoutes(stores, o = {}) {
const list = [].concat(stores || []);
const list = [].concat(stores || []);
@@ -80,6 +86,8 @@ function adminRoutes(stores, o = {}) {
@@ -80,6 +86,8 @@ function adminRoutes(stores, o = {}) {
const basePath = (o.basePath || '/api/admin/config').replace(/\/+$/, '');
const basePath = (o.basePath || '/api/admin/config').replace(/\/+$/, '');
const actorOf = o.actor || defaultActor;
const actorOf = o.actor || defaultActor;
const authorize = o.authorize || null;
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 log = makeLog(o.log);
const allowed = async (req, ctx) => !authorize || (await authorize(req, ctx)) !== false;
const allowed = async (req, ctx) => !authorize || (await authorize(req, ctx)) !== false;
@@ -90,6 +98,18 @@ function adminRoutes(stores, o = {}) {
@@ -90,6 +98,18 @@ function adminRoutes(stores, o = {}) {
return s;
return s;
}
}
const queryOf = (req) => req.query || Object.fromEntries(new URL(req.url || '/', 'http://x').searchParams);
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 = {
const handlers = {
async list(req, res) {
async list(req, res) {
@@ -120,6 +140,13 @@ function adminRoutes(stores, o = {}) {
@@ -120,6 +140,13 @@ function adminRoutes(stores, o = {}) {
const opts = { merge: body.merge === true, unset: body.unset || [], actor, reason: body.reason };
const opts = { merge: body.merge === true, unset: body.unset || [], actor, reason: body.reason };
const keys = await s.changes(body.values, opts);
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 });
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));
send(res, 200, await s.apply(body.values, opts));
},
},
async rollback(req, res) {
async rollback(req, res) {
@@ -129,9 +156,35 @@ function adminRoutes(stores, o = {}) {
@@ -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 });
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);
const actor = await actorOf(req);
if (!subjectRef(actor)) throw new ConfigError('config.actor_required', 'the request does not name the acting subject', { status: 403 });
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 });
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 = {}) {
@@ -161,12 +214,14 @@ function adminRoutes(stores, o = {}) {
{ method: 'get', path: `${basePath}/:namespace/history`, handler: out.history },
{ method: 'get', path: `${basePath}/:namespace/history`, handler: out.history },
{ method: 'post', path: `${basePath}/:namespace`, handler: out.apply },
{ method: 'post', path: `${basePath}/:namespace`, handler: out.apply },
{ method: 'post', path: `${basePath}/:namespace/rollback`, handler: out.rollback },
{ 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) {
function mount(router) {
for (const r of routes) router[r.method](r.path, r.handler);
for (const r of routes) router[r.method](r.path, r.handler);
return router;
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) {
function handle(req, res, next) {
const url = new URL(req.url || '/', 'http://x');
const url = new URL(req.url || '/', 'http://x');
const method = String(req.method || 'GET').toLowerCase();
const method = String(req.method || 'GET').toLowerCase();
@@ -179,11 +234,15 @@ function adminRoutes(stores, o = {}) {
@@ -179,11 +234,15 @@ function adminRoutes(stores, o = {}) {
else if (parts.length === 1 && method === 'post') m = ['apply', parts[0]];
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] === '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 === 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' });
if (!m) return typeof next === 'function' ? next() : sendProblem(res, 404, 'config.route_not_found', { detail: 'no such configuration route' });
let ns = m[1] || '';
let ns = m[1] || '';
try { ns = decodeURIComponent(ns); } catch { /* keep it as it is */ }
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);
if (!req.query) req.query = Object.fromEntries(url.searchParams);
return out[m[0]](req, res, next);
return out[m[0]](req, res, next);
}
}
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.109.2",
+ "version": "4.110.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.109.2",
+ "version": "4.110.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -534,16 +534,20 @@ function counting(db) {
@@ -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 } });
res = await call('POST', '/api/admin/config/live.site_settings', { body: { values: { youtube_api_key: 'AIzaNEW' }, merge: true } });
assertProblem(res.body, 403, 'config.forbidden');
assertProblem(res.body, 403, 'config.forbidden');
assert.strictEqual(live.get('youtube_api_key'), SECRET, 'authorize() refused a secret change');
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');
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 } });
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');
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');
assertProblem(res.body, 422, 'config.activation_failed');
assert.strictEqual(live.get('mode'), 'invite', 'the failure restored the previous values');
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' } });
res = await call('POST', '/api/admin/config/live.site_settings/rollback', { body: { reason: 'undo the key' } });
assertProblem(res.body, 403, 'config.forbidden');
assertProblem(res.body, 403, 'config.forbidden');
@@ -625,10 +629,11 @@ function counting(db) {
@@ -625,10 +629,11 @@ function counting(db) {
direct.list({ url: '/api/admin/config', admin: true }, allowedRes);
direct.list({ url: '/api/admin/config', admin: true }, allowedRes);
await sleep(5);
await sleep(5);
assert.strictEqual(allowedRes.statusCode, 200);
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}`), [
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',
'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',
'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');
assertNoLeak(logs.join('\n'), (await leakForms(db)).concat(await leakForms(legacyDb)), 'the whole log');
new file mode 100644
new file mode 100644
@@ -0,0 +1,488 @@
@@ -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); });