openvibers/openvibe.network Commits

The Locker key directory: public keys, wrapped backups and the key history (#577)

OP OpenVibers committed 7 days ago · ea98b2f409 · 11 files, +1204 −7

* The Locker key directory: public keys, wrapped backups and the key history

Network is the key directory for Locker (design 2026-11-06): it holds a person's public keys, their
self-signature and the private keys the browser sealed, and it can never read a private key.

- migrations/0039_locker_keys.sql: locker_keys, locker_key_backups (one row per kind) and the
  append-only locker_key_history
- server/locker/keys.js: PUT /api/v1/me/keys, GET /api/v1/me/keys/backup, DELETE /api/v1/me/keys,
  GET /api/v1/people/:subject/keys and GET /api/v1/people/@:username/keys (sixty a minute per caller)
- the fingerprint is computed here with fingerprintOf, the self-signature must verify with
  verifyPersonKeys, and every backup is checked with inspectEnvelope (a password lock, PBKDF2
  600000..10000000, at most 16 KB) and stored as it came
- a changed key appends the history and announces locker.keys.changed (ids and fingerprints only)
- no key, signature or blob reaches a log line, an event or an answer; every answer is private,
  no-store, Vary: Authorization, Cookie
- README.md and llms.txt get a Locker keys section
- openvibe-sdk is pinned to feat/locker-core-sdk until the release lands

* Merge main into feat/locker-keys (openvibe-shared v4.114.0) and keep the Locker SDK branch pin

* The migration list in the two rehearsal tests names the Locker tables

* A change of locker keys needs the key the person has now

Opus's review of #577: a session cookie alone must not be able to replace a person's keys, because
every new people-lock for that person would then be readable by whoever put them there.

- server/locker/proof.js: the bytes a person signs. A change is
  "openvibe-locker key-change v1|<subject>|<new fingerprint>|<sha256 of the canonical backups>",
  a forget is "openvibe-locker forget v1|<subject>". The helpers are exported so the browser
  (openvibe-shared/ov-locker.js) signs the same ones.
- PUT /api/v1/me/keys on a person who already has keys needs previous_signature: an ECDSA P-256
  signature by the CURRENT signing key. Without it, or with a bad one: 403 locker.proof_required.
  The proof covers a backups-only update too (the fingerprint does not change).
- a real reset (the passphrase and the recovery kit are gone) is { reset: true } with a sign-in
  from the last ten minutes (the session's auth_time), 403 locker.reauth_required otherwise. It
  replaces the whole locker and is written down as 'reset'.
- DELETE /api/v1/me/keys needs the same proof, over the forget bytes, or a recent sign-in.
- every change (created, rotated, restored, reset, forgotten) sends the person a notification, so
  a swap nobody asked for is seen.
- the README and llms.txt give the bytes to sign and what each change tells the person

* Locker keys: OpenVibe sites may write (ov-locker.js), user subdomains may not; refuse a change whose proof is about replaced keys

The cross-site guard refused every write that was not same-origin, so ov-locker.js, which sets a locker up from the site
the person is on (Community, Live…), could never save keys. Writes now pass from Network's exact first-party origins
(the CORS list, never the *.openvibe.tools wildcard where people host pages); every other origin is still refused.

The proof is checked against the keys read before the transaction: if another device replaced them in between, the
change is refused with 409 locker.conflict instead of being saved on a proof about keys that are gone.

Changes

ddiff --git a/README.md b/README.md
iindex 0d7fa40b..a90fa64a 100644
---- a/README.md
++++ b/README.md
@@@ -819,6 +819,9 @@ Accessible to users with `role = 'admin'`. All endpoints under `/api/admin/`.
| `user_modules`, `user_module_revisions`, `user_module_retirements` | User-module records, the last revision issued per (subject, namespace), retired namespace owners |
| `network_event_outbox` | Network's events on their way to OpenVibe.Events (developer projects, user modules, blocks) |
| `user_blocks` | Platform blocks by subject (blocker, blocked, active, per-pair revision) |
++| `locker_keys` | The Locker key directory: a person's public keys, their fingerprint and their self-signature |
++| `locker_key_backups` | The wrapped private keys, one row per person and kind (`passphrase`, `recovery`, `passkey`); opaque to Network |
++| `locker_key_history` | Every Locker key change, append-only: the fingerprint before and after |
| `user_follows` | The follow graph by subject (follower, target type and id, active, notify flags, per-pair revision, source) |
| `analytics_visitor_days`, `analytics_day_salts` | The current day's salted visitor hashes and salt, deleted after the day's final rollup |
@@@ -889,6 +892,134 @@ kind instead.
The event names and the payload shapes are Contracts share.v1, which is still being written; this code
waits for that release before it ships.
++## Locker keys
++
++The Locker (design 2026-11-06, `LOCKER-DESIGN-2026-11-06.md`) is end-to-end encryption for anything on
++OpenVibe: a locked item is stored and served as ciphertext, and the keys never reach our servers. Network
++is the key directory — it holds a person's **public** keys, their self-signature and a copy of the
++**private** keys that the browser sealed, and it can never read a private key. The format and the library
++are `openvibe-sdk/locker` and `openvibe-sdk/locker/server`; the browser seals the private keys, Network
++only checks the shape of what it is given.
++
++### What Network stores
++
++| Table | What it holds |
++|-------|---------------|
++| `locker_keys` | the two P-256 public keys (the ECDH one receives content keys, the ECDSA one signs the fingerprint), the eight-word fingerprint and the self-signature |
++| `locker_key_backups` | the wrapped private keys, one row per kind (`passphrase`, `recovery`, `passkey`), stored exactly as the browser sealed them |
++| `locker_key_history` | every key change, append-only: the fingerprint before and the fingerprint after |
++
++**What Network can read:** the public keys, the fingerprint, when the keys last changed, and the shape of
++a backup (its size, its lock and its PBKDF2 count).
++
++**What Network cannot read:** a private key, in any form. The private keys are wrapped by a key from the
++person's locker passphrase (PBKDF2-SHA-256, 600 000 iterations or more) or by a passkey, and the wrapped
++bytes are opaque here. If the passphrase and the recovery kit are both lost, nobody can open the locked
++items, including OpenVibe — but the person can start again with a **reset** (see below).
++
++### The proof: a change needs the key the person has now
++
++A session cookie on its own must not be able to change a person's keys: a stolen session that swapped in
++somebody else's keys would read everything locked for that person from then on. So a person who **already
++has keys** proves they still hold them: the **current** signing key signs the change, and the signature
++comes as `previous_signature` (base64, ECDSA P-256 over SHA-256). Without it the write is refused with
++`403 locker.proof_required`.
++
++The bytes to sign are these, as UTF-8 (`server/locker/proof.js`, exported so the browser signs the same
++ones):
++
++```
++openvibe-locker key-change v1|<subject>|<new fingerprint>|<backups digest>
++openvibe-locker forget v1|<subject>
++```
++
++- `<subject>` is the person's own `usr_…` id.
++- `<new fingerprint>` is the eight words the **new** keys produce (`fingerprintOf`).
++- `<backups digest>` is the lower-case hex SHA-256 of the **canonical JSON** of the backups that are
++ written: the keys of every object sorted, no spaces, one backup per kind:
++
++ ```json
++ {"passphrase":{"envelope":"<base64>","format":"locker.envelope.v1"}}
++ ```
++
++The same proof covers a backups-only update (the fingerprint does not change).
++
++### A reset, and forgetting
++
++- **A reset** is for the person who lost both their passphrase and their recovery kit: `PUT /api/v1/me/keys`
++ with `{"reset": true}` and no proof, allowed only when the session's sign-in is recent — the session
++ token's `auth_time` within the last ten minutes (the OIDC claim Network's `signToken` puts on every
++ session; the same rule account-merge and the account deletion use). Without it the write is refused with
++ `403 locker.reauth_required`. A reset replaces the whole locker: new keys, and the backups that come with
++ them; the old backups wrapped keys that are gone. It is written down as `reset`.
++- **Forgetting** (`DELETE /api/v1/me/keys`, with `{"confirm": "forget my locker"}`) needs the same proof, over
++ `openvibe-locker forget v1|<subject>`, or a recent sign-in.
++
++### The routes
++
++| Route | What it answers |
++|-------|-----------------|
++| `PUT /api/v1/me/keys` | my public keys, my self-signature and my wrapped backups |
++| `GET /api/v1/me/keys/backup` | my own wrapped private keys, for a new device — never anybody else's |
++| `DELETE /api/v1/me/keys` | forget my locker: the keys and the backups go, the history stays |
++| `GET /api/v1/people/:subject/keys` | a person's public keys and fingerprint, by subject |
++| `GET /api/v1/people/@:username/keys` | the same, by exact name |
++
++The lookups need a signed-in person and are rate-limited to sixty a minute for each caller. Every answer
++is `private, no-store`, and `Vary: Authorization, Cookie`.
++
++```bash
++# the public keys and the fingerprint of @maticus
++curl -s -H "Authorization: Bearer $TOKEN" https://openvibe.network/api/v1/people/@maticus/keys
++```
++
++```json
++{
++ "subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "ecdh_public": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…" },
++ "signing_public": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…" },
++ "fingerprint": "alpha bravo charlie delta-echo foxtrot golf hotel",
++ "updated_at": "2026-11-06T09:12:00.000Z",
++ "changed_at": "2026-11-06T09:12:00.000Z"
++}
++```
++
++`changed_at` is the last time the keys changed, so a share sheet can warn before it shares with a key the
++people have not seen before. The fingerprint is computed here, never taken from the caller: a caller that
++sends one that does not match its own keys is refused, and the self-signature must verify with the
++signing key.
++
++A backup is a Locker envelope with a password lock, base64, at most 16 KB, sealed with a PBKDF2 count
++between 600 000 and 10 000 000. Network checks that with `openvibe-sdk/locker/server` and stores the bytes
++as they came; it never opens one.
++
++```json
++{
++ "ecdh_public": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…" },
++ "signing_public": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…" },
++ "self_signature": "…",
++ "previous_signature": "…",
++ "backups": {
++ "passphrase": { "format": "locker.envelope.v1", "envelope": "…" }
++ }
++}
++```
++
++Every key change is announced on OpenVibe.Events as `locker.keys.changed`, with ids and fingerprints
++only: no key, no signature, no blob ever leaves this service in an event, in a log line or in an answer.
++The event's reason says why it happened: `created`, `rotated` (new keys), `restored` (the same keys, new
++backups), `reset` or `forgotten`.
++
++Every change also sends the person a Network notification, so a swap nobody asked for is seen:
++
++| Change | What the person is told |
++|--------|------------------------|
++| `created` | "Your locker was set up: the things you lock are opened with these keys." |
++| `rotated` | "Your locker keys changed: things locked for your old keys cannot be opened with the new ones." |
++| `restored` | "Your locker backup was restored: the things you lock are opened with the same keys." |
++| `reset` | "Your locker was reset: things locked for your old keys cannot be opened" |
++| `forgotten` | "Your locker was forgotten: your keys and your backups are gone." |
++
## License
MIT (`license` in [package.json](package.json)).
ddiff --git a/migrations/0039_locker_keys.sql b/migrations/0039_locker_keys.sql
nnew file mode 100644
iindex 00000000..38939a5b
---- /dev/null
++++ b/migrations/0039_locker_keys.sql
@@@ -0,0 +1,56 @@
++-- phase: expand
++-- The Locker key directory (design 2026-11-06, LOCKER-DESIGN-2026-11-06.md, section 4 "Network: the key directory").
++--
++-- A person's locker is two P-256 key pairs (an ECDH one that receives content keys, an ECDSA one that signs the
++-- fingerprint) and a copy of the private halves wrapped by a key from their locker passphrase. Network stores the
++-- public halves, the self-signature and the wrapped copies as opaque bytes: it can read the public keys and the
++-- fingerprint, and it can never read a private key.
++--
++-- Three tables:
++-- locker_keys one row per person: the public keys, the fingerprint and the self-signature.
++-- locker_key_backups one row per person and kind (passphrase, recovery, passkey): the wrapped private keys,
++-- stored exactly as the browser sealed them. Network checks the shape and never opens it.
++-- locker_key_history every key change, append-only: what the fingerprint was and what it became, so a share
++-- sheet can warn when a key changed since the people saw it. The reason says why: the keys
++-- were created, rotated (new keys), restored (the same keys, new backups), reset (the
++-- passphrase and the recovery kit were both lost) or forgotten.
++--
++-- Expand only: an older release never reads these tables, and nothing here changes an existing one.
++CREATE TABLE IF NOT EXISTS locker_keys (
++ subject text COLLATE "C" PRIMARY KEY,
++ ecdh_public jsonb NOT NULL,
++ signing_public jsonb NOT NULL,
++ fingerprint text COLLATE "C" NOT NULL,
++ self_signature text COLLATE "C" NOT NULL,
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso()
++);
++
++CREATE TABLE IF NOT EXISTS locker_key_backups (
++ subject text COLLATE "C" NOT NULL,
++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('passphrase', 'recovery', 'passkey')),
++ blob jsonb NOT NULL,
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
++ PRIMARY KEY (subject, kind)
++);
++
++CREATE TABLE IF NOT EXISTS locker_key_history (
++ id bigserial PRIMARY KEY,
++ subject text COLLATE "C" NOT NULL,
++ fingerprint text COLLATE "C" NOT NULL,
++ "from" text COLLATE "C",
++ "to" text COLLATE "C",
++ reason text COLLATE "C" NOT NULL CHECK (reason IN ('created', 'rotated', 'restored', 'reset', 'forgotten')),
++ changed_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso()
++);
++CREATE INDEX IF NOT EXISTS locker_key_history_subject ON locker_key_history (subject, id);
++
++-- Append-only: nothing updates or deletes a history row, ever. The history is the proof of what a key was.
++CREATE OR REPLACE FUNCTION locker_key_history_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN
++ RAISE EXCEPTION 'locker_key_history is append-only';
++END $$;
++DROP TRIGGER IF EXISTS locker_key_history_no_change ON locker_key_history;
++CREATE TRIGGER locker_key_history_no_change BEFORE UPDATE OR DELETE ON locker_key_history
++ FOR EACH ROW EXECUTE FUNCTION locker_key_history_append_only();
ddiff --git a/package-lock.json b/package-lock.json
iindex 4034eda7..32ad32de 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -21,7 +21,7 @@
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/heads/feat/locker-core-sdk",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.114.0",
"pg": "^8.23.0",
"qrcode": "1.5.4",
@@@ -1738,9 +1738,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.82.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
-- "integrity": "sha512-oJarlzFGEkqYY0eFumIRjJUqMpOT70kgbhgeDtA8QHnuMZR3VIGQubM2UlOaqXgkxD5uhdZ/G6jLHbjvUhqTLQ==",
++ "version": "0.86.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/heads/feat/locker-core-sdk",
++ "integrity": "sha512-MuTtcHtJSv4NgEBPy1nLso6RHppWPIbWzVBL24DtWFPdfVkH+B0yR0H3R73HDNP/Sc0MgKKn11GxcrnSmQlJkA==",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex a56e4360..a9ecb639 100644
---- a/package.json
++++ b/package.json
@@@ -25,7 +25,7 @@
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/heads/feat/locker-core-sdk",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.114.0",
"pg": "^8.23.0",
"qrcode": "1.5.4",
ddiff --git a/server/home/render.js b/server/home/render.js
iindex 0d841598..08c9eb78 100644
---- a/server/home/render.js
++++ b/server/home/render.js
@@@ -493,6 +493,14 @@ function llmsTxt() {
{ title: 'Tool catalog (JSON)', url: 'https://openvibe.tools/api/catalog.json' },
{ title: 'Tool domains (JSON)', url: 'https://openvibe.network/api/domains' },
] },
++ // The Locker key directory (design 2026-11-06): the public keys of a person, and what Network
++ // holds of a locker. The curl is here, so an agent can look a person's public key up.
++ { title: 'Locker keys', links: [
++ { title: 'Locker keys: a person\'s public keys and fingerprint', url: 'https://github.com/OpenVibers/OpenVibe.Network/blob/main/README.md#locker-keys',
++ note: 'Network holds the public keys, a self-signature and the wrapped private keys, and can never read a private key. Changing the keys needs a signature from the key the person has now. Look one up with: curl -s -H "Authorization: Bearer $TOKEN" https://openvibe.network/api/v1/people/@maticus/keys' },
++ { title: 'The Locker library and the envelope format (openvibe-sdk/locker)', url: 'https://github.com/OpenVibers/OpenVibe.SDK/blob/main/docs/locker.md',
++ note: 'how a locked item is sealed and opened, the three locks, and what a service may do with an envelope it cannot read' },
++ ] },
],
optional: [
...catalog.families.filter(f => f.url).map(f => ({ title: f.name, url: f.path ? 'https://openvibe.tools' + f.path : f.url, note: f.tagline })),
ddiff --git a/server/index.js b/server/index.js
iindex 03ae0a17..8ab44269 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -719,6 +719,14 @@ require('./developer/confirmations').start(db);
// the person it is talking with, and it counts down in the Dock's pill on every OpenVibe site. The service names
// the person and writes the timer and nothing else (network.dock.timer.write, granted to Chat alone).
app.use('/api/v1/people', require('./dock').serviceRouter({ dock }));
++ // The Locker key directory (design 2026-11-06, LOCKER-DESIGN-2026-11-06.md §4): my public keys, my
++ // wrapped private keys and anybody's public keys (server/locker/keys.js). Mounted after the Dock's
++ // service route, which writes the timer of one person.
++ const locker = require('./locker/keys');
++ // Writes come from Network itself or from an OpenVibe site's exact origin (ov-locker.js), never from *.openvibe.tools.
++ const isFirstPartyOrigin = (o) => { const n = normalizeOriginForCors(o); return !!n && buildAllowedOriginsSet().has(n); };
++ app.use('/api/v1/me/keys', locker.meRouter(requireAuth, { notifications: notificationService, isFirstPartyOrigin }));
++ app.use('/api/v1/people', locker.peopleRouter(requireAuth));
}
// The consent registry (L24, 2026-10-31; contracts 0.256.0): every use of a person's content or likeness for AI or
ddiff --git a/server/locker/keys.js b/server/locker/keys.js
nnew file mode 100644
iindex 00000000..e45507d4
---- /dev/null
++++ b/server/locker/keys.js
@@@ -0,0 +1,481 @@
++'use strict';
++/**
++ * The Locker key directory (design 2026-11-06, LOCKER-DESIGN-2026-11-06.md, section 4 "Network: the key
++ * directory"; the format and the library are openvibe-sdk/locker and openvibe-sdk/locker/server).
++ *
++ * A person's locker is two P-256 key pairs (an ECDH one that receives content keys, an ECDSA one that signs the
++ * fingerprint) plus a copy of the private halves wrapped by a key from their locker passphrase. Network holds the
++ * public halves, the self-signature and the wrapped copies as opaque bytes: it can read the public keys and the
++ * fingerprint, and it can never read a private key.
++ *
++ * PUT /api/v1/me/keys my public keys, my self-signature and my wrapped backups
++ * GET /api/v1/me/keys/backup my own wrapped private keys, for a new device (never anybody else's)
++ * DELETE /api/v1/me/keys forget my locker (keys and backups), with the confirmation text
++ * GET /api/v1/people/:subject/keys a person's public keys and fingerprint, by subject
++ * GET /api/v1/people/@:username/keys the same, by exact name
++ *
++ * What Network checks, and what it refuses:
++ * - the two public keys must be P-256 keys, and the self-signature must verify with the signing key
++ * (openvibe-sdk/locker verifyPersonKeys): a set nobody signed is not a person's set.
++ * - the fingerprint is computed here (fingerprintOf), never taken from the caller: a client that says another
++ * fingerprint than the keys produce is refused.
++ * - every backup must be a Locker envelope with a password lock, at most 16 KB, sealed with a PBKDF2 count
++ * between 600 000 and 10 000 000 (openvibe-sdk/locker/server inspectEnvelope). It is stored as it came.
++ * - a person who ALREADY has keys proves they still hold them: the current signing key signs the change
++ * (server/locker/proof.js), and without that signature nothing is changed (403 locker.proof_required).
++ * A stolen session cookie alone cannot swap in somebody else's keys.
++ * - a real reset (the passphrase and the recovery kit are both lost) is a `reset: true` with a sign-in from
++ * the last ten minutes (the session's auth_time), and it is written down as a reset.
++ * - every change (created, rotated, restored, reset, forgotten) sends the person a notification, so a swap
++ * nobody asked for is seen.
++ *
++ * Nothing here is ever logged: a key, a signature or a blob never reaches a log line, an error message or a
++ * response other than the one the person asked for. Every answer is private and never stored by a cache.
++ */
++const express = require('express');
++const rateLimit = require('express-rate-limit');
++const cache = require('openvibe-shared/cache-policy');
++const { http, ids, validate } = require('openvibe-contracts');
++const { fingerprintOf, verifyPersonKeys } = require('openvibe-sdk/locker');
++const { inspectEnvelope } = require('openvibe-sdk/locker/server');
++const eventRelay = require('../developer/event-relay');
++const proof = require('./proof');
++// A real sign-in (the session's auth_time within ten minutes), the rule account-merge and the account deletion
++// already use: never a renewal, never a QR session carrying an old source time.
++const { isFreshSignIn } = require('../identity/account-merge');
++
++/** The event a key change announces (ids and fingerprints only; never a key, never a blob). */
++const EVENT_TYPE = 'locker.keys.changed';
++/** The only shape a backup may have: a Locker envelope, base64. */
++const BACKUP_FORMAT = 'locker.envelope.v1';
++/** The kinds of backup a person may keep: one row per kind. */
++const BACKUP_KINDS = ['passphrase', 'recovery', 'passkey'];
++/** A backup may not be bigger than this (the wrapped private keys are small; 16 KB is generous). */
++const MAX_BACKUP_BYTES = 16 * 1024;
++/** The PBKDF2 count we accept for a password lock: the floor and the ceiling the SDK uses. */
++const PASSWORD_ITERATIONS_MIN = 600000;
++const PASSWORD_ITERATIONS_MAX = 10000000;
++/** The text a person must type to forget their locker. */
++const CONFIRM_TEXT = 'forget my locker';
++/** A subject id (usr_<ULID>). */
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++/** A name (letters, digits, dot, dash, underscore): exact names only, never a pattern. */
++const NAME_RE = /^[A-Za-z0-9_.-]{1,40}$/;
++/** The eight words of a fingerprint, in two groups of four. */
++const FINGERPRINT_RE = /^[a-z]+ [a-z]+ [a-z]+ [a-z]+-[a-z]+ [a-z]+ [a-z]+ [a-z]+$/;
++/** A base64 ECDSA P-256 signature: 64 bytes, 86 characters and the padding. */
++const SIGNATURE_RE = /^[A-Za-z0-9+/]{86}==$/;
++/** How many lookups one caller may do a minute. */
++const LOOKUP_LIMIT = 60;
++const LOOKUP_WINDOW_MS = 60_000;
++/** The reasons a locker's history holds. */
++const REASONS = ['created', 'rotated', 'restored', 'reset', 'forgotten'];
++/** What each change tells the person. The reset's words are fixed: they say what is lost. */
++const NOTICES = {
++ created: 'Your locker was set up: the things you lock are opened with these keys.',
++ rotated: 'Your locker keys changed: things locked for your old keys cannot be opened with the new ones.',
++ restored: 'Your locker backup was restored: the things you lock are opened with the same keys.',
++ reset: 'Your locker was reset: things locked for your old keys cannot be opened',
++ forgotten: 'Your locker was forgotten: your keys and your backups are gone.',
++};
++
++class LockerError extends Error {
++ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
++}
++
++/** A P-256 public key as a JWK: the four members a browser's WebCrypto writes. */
++function publicKeyOk(key) {
++ return !!key && typeof key === 'object' && !Array.isArray(key)
++ && key.kty === 'EC' && key.crv === 'P-256'
++ && typeof key.x === 'string' && /^[A-Za-z0-9_-]{43}$/.test(key.x)
++ && typeof key.y === 'string' && /^[A-Za-z0-9_-]{43}$/.test(key.y);
++}
++
++/** The bytes of a base64 string, or null when the text is not base64 at all. */
++function fromBase64(text) {
++ if (typeof text !== 'string' || !text.length || text.length % 4 !== 0) return null;
++ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(text)) return null;
++ let bytes;
++ try { bytes = Buffer.from(text, 'base64'); } catch { return null; }
++ // Round-trip: base64 that does not come back the same was not base64.
++ if (bytes.toString('base64') !== text) return null;
++ return bytes;
++}
++
++/**
++ * One backup: a Locker envelope with a password lock, checked with the SDK's server rules and stored as it came.
++ * Never opens it, never logs it: the bytes are only read to look at the header.
++ */
++function checkBackup(blob) {
++ if (!blob || typeof blob !== 'object' || Array.isArray(blob)) {
++ throw new LockerError(400, 'locker.bad_backup', 'a backup is { format, envelope }');
++ }
++ if (blob.format !== BACKUP_FORMAT) {
++ throw new LockerError(400, 'locker.bad_backup', 'the backup is not a Locker envelope');
++ }
++ const bytes = fromBase64(blob.envelope);
++ if (!bytes) throw new LockerError(400, 'locker.bad_backup', 'the backup envelope is not base64');
++ if (bytes.length > MAX_BACKUP_BYTES) {
++ throw new LockerError(413, 'locker.backup_too_large', 'the backup is bigger than 16 KB');
++ }
++ const info = inspectEnvelope(bytes);
++ // The lock and the derivation count say what the blob is, so they are read before the whole shape is judged.
++ const header = info.header || {};
++ if (header.lock && header.lock !== 'password') {
++ throw new LockerError(400, 'locker.backup_not_password', 'the backup must be sealed with a password');
++ }
++ const iterations = header.slots && header.slots.password ? header.slots.password.iterations : null;
++ if (iterations != null && (!Number.isInteger(iterations)
++ || iterations < PASSWORD_ITERATIONS_MIN || iterations > PASSWORD_ITERATIONS_MAX)) {
++ throw new LockerError(400, 'locker.bad_iterations', 'the backup uses a password derivation we do not accept');
++ }
++ if (!info.valid) throw new LockerError(400, 'locker.bad_backup', 'the backup is not a Locker envelope we accept');
++ return { format: BACKUP_FORMAT, envelope: blob.envelope };
++}
++
++/** The signed-in person as a subject, or a LockerError (a guest has no locker). */
++async function meOf(db, user) {
++ if (!user || user.is_anon) throw new LockerError(403, 'locker.guest', 'sign in with an account to use a locker');
++ const subject = await require('../identity/subjects').ensureUserSubject(db, user);
++ if (!SUBJECT_RE.test(String(subject || ''))) {
++ throw new LockerError(403, 'locker.guest', 'sign in with an account to use a locker');
++ }
++ return subject;
++}
++
++/** The person's key row, or undefined. */
++async function keyRow(db, subject) {
++ return await db.prepare(`SELECT subject, ecdh_public, signing_public, fingerprint, self_signature, created_at, updated_at
++ FROM locker_keys WHERE subject = ?`).get(String(subject));
++}
++
++/** When the person's keys last changed (the newest history row), or null. */
++async function changedAtOf(db, subject) {
++ const row = await db.prepare('SELECT changed_at FROM locker_key_history WHERE subject = ? ORDER BY id DESC LIMIT 1').get(String(subject));
++ return row ? row.changed_at : null;
++}
++
++/** What anybody may read about a person's locker: the public keys, the fingerprint and when they changed. */
++function view(row, changedAt) {
++ return {
++ subject: row.subject,
++ ecdh_public: row.ecdh_public,
++ signing_public: row.signing_public,
++ fingerprint: row.fingerprint,
++ updated_at: row.updated_at,
++ changed_at: changedAt || null,
++ };
++}
++
++/** The envelope for one key change. Ids and fingerprints only: no key, no signature, no blob. */
++function buildEnvelope({ subject, fingerprint, previous, reason, at }) {
++ const ms = Date.parse(at) || Date.now();
++ const env = {
++ event_id: ids.newId('event', ms), event_type: EVENT_TYPE, version: 1, source: 'network',
++ actor: { type: 'user', id: subject }, timestamp: new Date(ms).toISOString(), visibility: 'subject',
++ subject: { type: 'user', id: subject },
++ payload: {
++ subject: { type: 'user', id: subject },
++ fingerprint: fingerprint || null,
++ previous_fingerprint: previous || null,
++ reason,
++ changed_at: new Date(ms).toISOString(),
++ },
++ };
++ const v = validate('events.event-envelope@1', env);
++ if (!v.valid) throw new LockerError(500, 'locker.event_failed', 'the key change could not be announced');
++ return env;
++}
++
++/**
++ * The notification a key change sends the person, so a swap nobody asked for is seen: the bell, the email and the
++ * push all carry the same words. Nothing about the keys themselves: the notice says that they changed, not what
++ * they are.
++ */
++async function notifyKeyChange(notifications, user, reason) {
++ if (!notifications || !user) return null;
++ const message = NOTICES[reason] || NOTICES.rotated;
++ try {
++ return await notifications.create({
++ user_id: user.id, type: 'GENERIC', category: 'system', priority: 'high', icon: '🔒',
++ title: 'Your locker changed', message, service: 'network',
++ url: 'https://openvibe.network/my',
++ });
++ } catch (err) {
++ // The change is already written; a notice that could not be sent must not undo it.
++ console.error('[Locker]', 'the notice could not be sent:', safeMessage(err));
++ return null;
++ }
++}
++
++/**
++ * A write another site started (CSRF), judged from what the browser says: Sec-Fetch-Site other than same-origin
++ * (or none: typed by the person), or an Origin on another host. A client that sends neither (an agent with a
++ * bearer token) is not a browser and is let through.
++ */
++function crossSite(req, isFirstPartyOrigin = null) {
++ // An OpenVibe site (the exact first-party origins Network's CORS lists, never a wildcard such as *.openvibe.tools,
++ // where people host their own pages): ov-locker.js sets a locker up from the site the person is on.
++ const from = req.get('origin');
++ if (from && from !== 'null' && typeof isFirstPartyOrigin === 'function' && isFirstPartyOrigin(from)) return false;
++ const site = req.get('sec-fetch-site');
++ if (site && site !== 'same-origin' && site !== 'none') return true;
++ const origin = req.get('origin');
++ if (!origin || origin === 'null') return false;
++ const host = String(req.get('host') || '').toLowerCase();
++ try {
++ const o = new URL(origin);
++ const same = o.host.toLowerCase() === host;
++ const forwarded = req.get('x-forwarded-host');
++ return !(same || (forwarded && o.host.toLowerCase() === String(forwarded).split(',')[0].trim().toLowerCase()));
++ } catch { return true; }
++}
++
++/** Every answer here is private and never kept by a cache. */
++function privateAnswer(res) {
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ res.set('Vary', 'Authorization, Cookie');
++}
++
++/**
++ * An error message that cannot carry a key, a signature or a blob: a private key, a signature and an envelope
++ * are all long runs of base64, and those are redacted before the message is written anywhere.
++ */
++function safeMessage(err) {
++ const text = String((err && err.message) || err || '');
++ return text.replace(/[A-Za-z0-9+/=_-]{40,}/g, '[redacted]').slice(0, 200);
++}
++
++/** The one error path: a problem answer, and a log line that says nothing about a key. */
++function send(req, res, fn) {
++ return (async () => {
++ try {
++ return await fn(req.app.locals.db);
++ } catch (err) {
++ if (err instanceof LockerError) {
++ return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
++ }
++ // A log line that cannot carry a key, a signature or a blob: anything long and base64-ish (a
++ // private key, a signature, an envelope) is redacted before it is written.
++ console.error('[Locker]', 'unexpected error:', safeMessage(err));
++ return http.sendProblem(res, 500, 'locker.failed', { detail: 'the locker could not be read or changed', ctx: req.ov });
++ }
++ })();
++}
++
++/**
++ * The person's own locker: PUT /api/v1/me/keys, GET /api/v1/me/keys/backup, DELETE /api/v1/me/keys.
++ */
++function meRouter(requireAuth, { notifications = null, isFirstPartyOrigin = null } = {}) {
++ const router = express.Router();
++ router.use(http.middleware());
++
++ // Set or rotate my keys. The same keys again change nothing and announce nothing.
++ router.put('/', requireAuth, async (req, res) => await send(req, res, async (db) => {
++ if (crossSite(req, isFirstPartyOrigin)) throw new LockerError(403, 'locker.cross_site', 'this write came from another site');
++ const me = await meOf(db, req.user);
++ const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {};
++ const ecdh = body.ecdh_public;
++ const signing = body.signing_public;
++ const signature = body.self_signature;
++ if (!publicKeyOk(ecdh) || !publicKeyOk(signing)) {
++ throw new LockerError(400, 'locker.bad_keys', 'the public keys are not P-256 keys');
++ }
++ if (typeof signature !== 'string' || !SIGNATURE_RE.test(signature)) {
++ throw new LockerError(400, 'locker.bad_signature', 'the self-signature is not a signature');
++ }
++ // The fingerprint is computed here. A caller that sends one that does not match its own keys is refused.
++ const fingerprint = await fingerprintOf(ecdh, signing);
++ if (!FINGERPRINT_RE.test(fingerprint)) throw new LockerError(500, 'locker.failed', 'the fingerprint could not be computed');
++ if (body.fingerprint != null && body.fingerprint !== fingerprint) {
++ throw new LockerError(400, 'locker.fingerprint_mismatch', 'the fingerprint does not match the keys');
++ }
++ // The signature must verify with the signing key: a set nobody signed is not a person's set.
++ const signed = await verifyPersonKeys({ ecdh, signing, fingerprint_signature: signature, fingerprint });
++ if (!signed) throw new LockerError(400, 'locker.bad_signature', 'the self-signature does not verify with the signing key');
++
++ const given = body.backups && typeof body.backups === 'object' && !Array.isArray(body.backups) ? body.backups : {};
++ const kinds = Object.keys(given);
++ for (const kind of kinds) {
++ if (!BACKUP_KINDS.includes(kind)) throw new LockerError(400, 'locker.unknown_backup', 'a backup is passphrase, recovery or passkey');
++ }
++ const backups = {};
++ for (const kind of kinds) backups[kind] = checkBackup(given[kind]);
++
++ const prev = await keyRow(db, me);
++ const reset = body.reset === true;
++ // The proof: the CURRENT signing key signs the change (server/locker/proof.js). Without it, a stolen
++ // session could swap in somebody else's keys and read everything locked for this person from then on.
++ let proved = false;
++ if (prev && typeof body.previous_signature === 'string' && body.previous_signature.length) {
++ const bytes = await proof.keyChangeProofBytes(me, fingerprint, backups);
++ proved = await proof.verifyProof(prev.signing_public, bytes, body.previous_signature);
++ }
++ if (prev && !proved) {
++ if (!reset) {
++ throw new LockerError(403, 'locker.proof_required', 'the change needs a signature from the key you have now');
++ }
++ // A real reset: the passphrase and the recovery kit are gone. A sign-in from the last ten minutes
++ // (the session's auth_time) is the only thing that can open this door.
++ if (!isFreshSignIn(req.tokenClaims)) {
++ throw new LockerError(403, 'locker.reauth_required', 'sign in again (within the last 10 minutes) to reset your locker');
++ }
++ }
++
++ const now = new Date().toISOString();
++ const out = await db.tx(async () => {
++ const before = await keyRow(db, me);
++ // The proof was checked against the keys read before this transaction. If another device changed them
++ // in between, that proof is about keys that are gone: refuse, and let the person reload.
++ if ((prev ? prev.fingerprint : null) !== (before ? before.fingerprint : null)) {
++ throw new LockerError(409, 'locker.conflict', 'your locker changed on another device: reload and try again');
++ }
++ const held = await db.prepare('SELECT kind, blob FROM locker_key_backups WHERE subject = ?').all(me);
++ const beforeBackups = {};
++ for (const row of held) beforeBackups[row.kind] = row.blob;
++ const backupsChanged = Object.keys(backups).some((kind) => JSON.stringify(beforeBackups[kind] || null) !== JSON.stringify(backups[kind]));
++ const changed = !before || before.fingerprint !== fingerprint || reset || backupsChanged;
++ // Why it changed: the first keys, a reset, new keys, or the same keys with new backups.
++ const reason = !before ? 'created' : (reset ? 'reset' : (before.fingerprint !== fingerprint ? 'rotated' : 'restored'));
++ if (before) {
++ await db.prepare(`UPDATE locker_keys SET ecdh_public = ?::jsonb, signing_public = ?::jsonb,
++ fingerprint = ?, self_signature = ?, updated_at = ? WHERE subject = ?`)
++ .run(JSON.stringify(ecdh), JSON.stringify(signing), fingerprint, signature, now, me);
++ } else {
++ await db.prepare(`INSERT INTO locker_keys (subject, ecdh_public, signing_public, fingerprint, self_signature, created_at, updated_at)
++ VALUES (?, ?::jsonb, ?::jsonb, ?, ?, ?, ?)`)
++ .run(me, JSON.stringify(ecdh), JSON.stringify(signing), fingerprint, signature, now, now);
++ }
++ // A reset replaces the whole locker: the old backups wrap keys that are gone.
++ if (reset) await db.prepare('DELETE FROM locker_key_backups WHERE subject = ?').run(me);
++ for (const kind of Object.keys(backups)) {
++ const blob = JSON.stringify(backups[kind]);
++ const has = await db.prepare('SELECT created_at FROM locker_key_backups WHERE subject = ? AND kind = ?').get(me, kind);
++ if (has) {
++ await db.prepare('UPDATE locker_key_backups SET blob = ?::jsonb, updated_at = ? WHERE subject = ? AND kind = ?')
++ .run(blob, now, me, kind);
++ } else {
++ await db.prepare('INSERT INTO locker_key_backups (subject, kind, blob, created_at, updated_at) VALUES (?, ?, ?::jsonb, ?, ?)')
++ .run(me, kind, blob, now, now);
++ }
++ }
++ if (changed) {
++ await db.prepare(`INSERT INTO locker_key_history (subject, fingerprint, "from", "to", reason, changed_at)
++ VALUES (?, ?, ?, ?, ?, ?)`).run(me, fingerprint, before ? before.fingerprint : null, fingerprint, reason, now);
++ await eventRelay.writerFor(db).enqueue(buildEnvelope({
++ subject: me, fingerprint, previous: before ? before.fingerprint : null, reason, at: now,
++ }));
++ }
++ return { changed, reason };
++ });
++ if (out.changed) await notifyKeyChange(notifications, req.user, out.reason);
++ const row = await keyRow(db, me);
++ const changedAt = await changedAtOf(db, me);
++ privateAnswer(res);
++ res.json({ ...view(row, changedAt), changed: out.changed, reason: out.reason });
++ }));
++
++ // My own wrapped private keys, for a new device. Nobody else's: this route only ever answers about the caller.
++ router.get('/backup', requireAuth, async (req, res) => await send(req, res, async (db) => {
++ const me = await meOf(db, req.user);
++ const rows = await db.prepare('SELECT kind, blob FROM locker_key_backups WHERE subject = ? ORDER BY kind').all(me);
++ const backups = {};
++ for (const row of rows) backups[row.kind] = row.blob;
++ privateAnswer(res);
++ res.json({ subject: me, backups });
++ }));
++
++ // Forget my locker: the keys and the backups go, the history stays. The current signing key says so, or the
++ // person signed in again in the last ten minutes.
++ router.delete('/', requireAuth, async (req, res) => await send(req, res, async (db) => {
++ if (crossSite(req, isFirstPartyOrigin)) throw new LockerError(403, 'locker.cross_site', 'this write came from another site');
++ const me = await meOf(db, req.user);
++ const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {};
++ if (body.confirm !== CONFIRM_TEXT) {
++ throw new LockerError(400, 'locker.confirm_required', 'the confirmation text does not match');
++ }
++ const prev = await keyRow(db, me);
++ if (!prev) throw new LockerError(404, 'locker.no_keys', 'there are no keys to forget');
++ let proved = false;
++ if (typeof body.previous_signature === 'string' && body.previous_signature.length) {
++ proved = await proof.verifyProof(prev.signing_public, proof.forgetProofBytes(me), body.previous_signature);
++ }
++ if (!proved && !isFreshSignIn(req.tokenClaims)) {
++ throw new LockerError(403, 'locker.proof_required', 'the change needs a signature from the key you have now, or a sign-in from the last 10 minutes');
++ }
++ const now = new Date().toISOString();
++ await db.tx(async () => {
++ await db.prepare('DELETE FROM locker_key_backups WHERE subject = ?').run(me);
++ await db.prepare('DELETE FROM locker_keys WHERE subject = ?').run(me);
++ await db.prepare(`INSERT INTO locker_key_history (subject, fingerprint, "from", "to", reason, changed_at)
++ VALUES (?, ?, ?, NULL, 'forgotten', ?)`).run(me, prev.fingerprint, prev.fingerprint, now);
++ await eventRelay.writerFor(db).enqueue(buildEnvelope({
++ subject: me, fingerprint: null, previous: prev.fingerprint, reason: 'forgotten', at: now,
++ }));
++ });
++ await notifyKeyChange(notifications, req.user, 'forgotten');
++ privateAnswer(res);
++ res.json({ subject: me, forgotten: true });
++ }));
++
++ return router;
++}
++
++/**
++ * The public key directory: anybody signed in may look up one person's public keys and fingerprint, by subject or
++ * by exact name. Nobody may read anybody's backup here: there is no such route.
++ */
++function peopleRouter(requireAuth) {
++ const router = express.Router();
++ router.use(http.middleware());
++ const limit = rateLimit({
++ windowMs: LOOKUP_WINDOW_MS,
++ max: LOOKUP_LIMIT,
++ keyGenerator: (req) => `locker-${req.user && req.user.id ? req.user.id : 'none'}`,
++ message: { error: 'too_many_requests', error_description: 'sixty locker key lookups a minute' },
++ });
++
++ /** The person's public keys, or a LockerError (no such person, or no keys). */
++ const readKeys = async (db, subject) => {
++ const row = await keyRow(db, subject);
++ if (!row) throw new LockerError(404, 'locker.no_keys', 'that person has no locker keys');
++ return view(row, await changedAtOf(db, subject));
++ };
++
++ // By exact name: the name a person is known by, nothing else.
++ router.get('/@:username/keys', requireAuth, limit, async (req, res) => await send(req, res, async (db) => {
++ const name = String(req.params.username || '').trim();
++ if (!NAME_RE.test(name)) throw new LockerError(404, 'locker.unknown_person', 'no such person');
++ const person = await require('../identity/subjects').resolveUsername(db, name);
++ if (!person) throw new LockerError(404, 'locker.unknown_person', 'no such person');
++ const out = await readKeys(db, person.subject.id);
++ privateAnswer(res);
++ res.json(out);
++ }));
++
++ // By subject.
++ router.get('/:target/keys', requireAuth, limit, async (req, res) => await send(req, res, async (db) => {
++ const target = String(req.params.target || '').trim();
++ if (!SUBJECT_RE.test(target)) throw new LockerError(404, 'locker.unknown_person', 'no such person');
++ const out = await readKeys(db, target);
++ privateAnswer(res);
++ res.json(out);
++ }));
++
++ return router;
++}
++
++module.exports = {
++ EVENT_TYPE, BACKUP_FORMAT, BACKUP_KINDS, MAX_BACKUP_BYTES, CONFIRM_TEXT, LOOKUP_LIMIT, REASONS, NOTICES,
++ LockerError, publicKeyOk, checkBackup, view, buildEnvelope, crossSite, safeMessage, notifyKeyChange,
++ meRouter, peopleRouter,
++ // The bytes a person signs, so the browser (openvibe-shared/ov-locker.js) signs the same ones.
++ canonicalJson: proof.canonicalJson,
++ canonicalBackups: proof.canonicalBackups,
++ backupsDigest: proof.backupsDigest,
++ keyChangeProofMessage: proof.keyChangeProofMessage,
++ keyChangeProofBytes: proof.keyChangeProofBytes,
++ forgetProofMessage: proof.forgetProofMessage,
++ forgetProofBytes: proof.forgetProofBytes,
++ verifyProof: proof.verifyProof,
++};
ddiff --git a/server/locker/proof.js b/server/locker/proof.js
nnew file mode 100644
iindex 00000000..cff07a53
---- /dev/null
++++ b/server/locker/proof.js
@@@ -0,0 +1,114 @@
++'use strict';
++/**
++ * The bytes a person signs to change their locker (design 2026-11-06, LOCKER-DESIGN-2026-11-06.md; Opus's review
++ * of Network#577: a stolen session must not be able to swap in somebody else's keys).
++ *
++ * A person who already has keys proves they still hold them: the **current** signing key signs a message that
++ * names the subject, the fingerprint the keys are becoming and a digest of the backups that are being written.
++ * Without that signature a change is refused, whatever the session cookie says.
++ *
++ * key change "openvibe-locker key-change v1|<subject>|<new fingerprint>|<sha256 of the canonical backups>"
++ * forget "openvibe-locker forget v1|<subject>"
++ *
++ * The bytes are UTF-8 of that text. The fingerprint is the eight words the SDK computes (fingerprintOf). The
++ * backups digest is the **lower-case hex SHA-256** of the canonical JSON of the backups that are written:
++ *
++ * {"passphrase":{"envelope":"<base64>","format":"locker.envelope.v1"}}
++ *
++ * The canonical JSON sorts the keys of every object and writes no spaces, so the same backups always give the
++ * same bytes in the browser and on the server. The browser (openvibe-shared/ov-locker.js) signs these exact
++ * bytes with the person's ECDSA P-256 signing key and sends the signature as `previous_signature`, base64.
++ *
++ * These helpers are exported so the browser can produce the same bytes; the rules above are the contract, and
++ * test/locker-keys.test.js pins them.
++ */
++/** WebCrypto, or a clear error when the runtime has none. */
++function subtle() {
++ const crypto = globalThis.crypto;
++ if (!crypto || !crypto.subtle) throw new Error('locker: this runtime has no WebCrypto (globalThis.crypto.subtle)');
++ return crypto.subtle;
++}
++
++/** The canonical JSON of a value: the keys sorted, no spaces, the same bytes everywhere. */
++function canonicalJson(value) {
++ if (value === undefined || value === null) return 'null';
++ if (typeof value !== 'object') return JSON.stringify(value);
++ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
++ const keys = Object.keys(value).sort();
++ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
++}
++
++/** The lower-case hex SHA-256 of some bytes. */
++async function sha256Hex(bytes) {
++ const digest = await subtle().digest('SHA-256', bytes);
++ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
++}
++
++/** The canonical JSON of the backups that are written: { kind: { envelope, format } }, the kinds sorted. */
++function canonicalBackups(backups) {
++ const out = {};
++ for (const kind of Object.keys(backups || {}).sort()) {
++ out[kind] = { envelope: String(backups[kind].envelope), format: String(backups[kind].format) };
++ }
++ return canonicalJson(out);
++}
++
++/** The digest of the backups: the hex SHA-256 of their canonical JSON. → 64 hex characters */
++async function backupsDigest(backups) {
++ return await sha256Hex(new TextEncoder().encode(canonicalBackups(backups)));
++}
++
++/** The message a person signs to change their keys. */
++async function keyChangeProofMessage(subject, fingerprint, backups) {
++ return `openvibe-locker key-change v1|${subject}|${fingerprint}|${await backupsDigest(backups)}`;
++}
++
++/** The bytes a person signs to change their keys. */
++async function keyChangeProofBytes(subject, fingerprint, backups) {
++ return new TextEncoder().encode(await keyChangeProofMessage(subject, fingerprint, backups));
++}
++
++/** The message a person signs to forget their locker. */
++function forgetProofMessage(subject) {
++ return `openvibe-locker forget v1|${subject}`;
++}
++
++/** The bytes a person signs to forget their locker. */
++function forgetProofBytes(subject) {
++ return new TextEncoder().encode(forgetProofMessage(subject));
++}
++
++/** The bytes of a base64 (or base64url) signature, or null when it is not one of 64 bytes. */
++function signatureBytes(text) {
++ if (typeof text !== 'string' || !text.length) return null;
++ const plain = /^[A-Za-z0-9+/]+={0,2}$/.test(text) && text.length % 4 === 0 ? text : null;
++ const url = /^[A-Za-z0-9_-]+$/.test(text) ? Buffer.from(text, 'base64url') : null;
++ let bytes = null;
++ try { bytes = plain ? Buffer.from(plain, 'base64') : url; } catch { return null; }
++ if (!bytes || bytes.length !== 64) return null; // an ECDSA P-256 signature is 64 bytes: r and s
++ return new Uint8Array(bytes);
++}
++
++/**
++ * Does this signature, made by the key in `publicKey`, cover these bytes? WebCrypto only, as everywhere else in
++ * the Locker. A key that is not a P-256 key, a signature that is not one, or anything that goes wrong is a false.
++ */
++async function verifyProof(publicKey, messageBytes, signature) {
++ const bytes = signatureBytes(signature);
++ if (!bytes || !publicKey || publicKey.kty !== 'EC' || publicKey.crv !== 'P-256') return false;
++ try {
++ const key = await subtle().importKey(
++ 'jwk', { kty: publicKey.kty, crv: publicKey.crv, x: publicKey.x, y: publicKey.y },
++ { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify'],
++ );
++ return await subtle().verify({ name: 'ECDSA', hash: 'SHA-256' }, key, bytes, messageBytes);
++ } catch {
++ return false;
++ }
++}
++
++module.exports = {
++ canonicalJson, canonicalBackups, sha256Hex, backupsDigest,
++ keyChangeProofMessage, keyChangeProofBytes, forgetProofMessage, forgetProofBytes,
++ signatureBytes, verifyProof,
++};
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex de0d4e26..48db5c22 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand']]);
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
ddiff --git a/test/locker-keys.test.js b/test/locker-keys.test.js
nnew file mode 100644
iindex 00000000..96db608e
---- /dev/null
++++ b/test/locker-keys.test.js
@@@ -0,0 +1,399 @@
++'use strict';
++/**
++ * The Locker key directory (design 2026-11-06, LOCKER-DESIGN-2026-11-06.md §4; Opus's review of #577): Network
++ * holds a person's public keys, their self-signature and their wrapped private keys, and never a private key in
++ * the clear — and a stolen session cannot swap in somebody else's keys, because a change needs the signature of
++ * the key the person has now.
++ *
++ * node test/locker-keys.test.js
++ *
++ * What is checked here: a bad self-signature and a mismatched fingerprint are refused; an oversized blob and a
++ * blob with a low PBKDF2 count are refused; one person's backup is never readable by another; the public keys are
++ * found by subject and by exact name; a key change is written to the history and announced; the keys and the
++ * backups can be forgotten; the lookups are rate-limited per caller; no key, signature or blob ever reaches a log
++ * line. And the proof itself: a rotation with a good proof, with a bad proof and with none; a reset with a
++ * recent sign-in and with a stale one; a forget with a proof and with a recent sign-in; and the notice every
++ * change sends the person.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++const express = require('express');
++const { ids } = require('openvibe-contracts');
++const { getDb } = require('../server/db/database');
++const { generatePersonKeys, exportPersonKeys, exportPrivateKeys, seal } = require('openvibe-sdk/locker');
++
++(async () => {
++const db = getDb();
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
++const { signToken } = require('../server/auth/routes');
++const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);
++const locker = require('../server/locker/keys');
++const { NotificationService } = require('../server/notifications/notification-service');
++const notifications = new NotificationService(db);
++
++const ANN = ids.newId('user');
++const BOB = ids.newId('user');
++const RATELIMIT = ids.newId('user');
++await db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id, role) VALUES
++ (1, 'ann', 'Ann', 'x', ?, 'user'), (2, 'bob', 'Bob', 'x', ?, 'user'), (3, 'ratelimit', 'Rate', 'x', ?, 'user')`)
++ .run(ANN, BOB, RATELIMIT);
++
++const app = express();
++app.use(require('cookie-parser')());
++app.use(express.json({ limit: '1mb' }));
++app.use(express.urlencoded({ extended: true }));
++Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
++const FIRST_PARTY = new Set(['https://openvibe.community', 'https://openvibe.live']);
++app.use('/api/v1/me/keys', locker.meRouter(requireAuth, { notifications, isFirstPartyOrigin: (o) => FIRST_PARTY.has(o) }));
++app.use('/api/v1/people', locker.peopleRouter(requireAuth));
++const server = http.createServer(app);
++
++/** A person's keys, as the browser would send them: the public halves and the self-signature. */
++async function published() {
++ const person = await generatePersonKeys();
++ const pub = await exportPersonKeys(person);
++ const priv = await exportPrivateKeys(person);
++ return {
++ person, priv,
++ body: { ecdh_public: pub.ecdh, signing_public: pub.signing, self_signature: pub.fingerprint_signature },
++ fingerprint: pub.fingerprint,
++ };
++}
++
++/** A backup: the private keys sealed with a passphrase, as a Locker envelope in base64. */
++async function backupOf(priv, password = 'a good passphrase') {
++ const sealed = await seal(JSON.stringify(priv), { lock: 'password', password });
++ return { format: 'locker.envelope.v1', envelope: Buffer.from(sealed.bytes).toString('base64') };
++}
++
++/** The same envelope with its header changed: the bytes a hostile client would send. */
++function repatch(bytes, mutate) {
++ const headerLength = bytes.readUInt32BE(4);
++ const header = JSON.parse(bytes.subarray(8, 8 + headerLength).toString('utf8'));
++ mutate(header);
++ const headerBytes = Buffer.from(JSON.stringify(header), 'utf8');
++ const length = Buffer.alloc(4);
++ length.writeUInt32BE(headerBytes.length);
++ return Buffer.concat([bytes.subarray(0, 4), length, headerBytes, bytes.subarray(8 + headerLength)]);
++}
++
++/** A signature over some bytes, by a person's signing key: what the browser sends as previous_signature. */
++async function sign(person, bytes) {
++ const signature = await globalThis.crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, person.signing.privateKey, bytes);
++ return Buffer.from(signature).toString('base64');
++}
++
++const events = async () => (await db.prepare("SELECT envelope FROM network_event_outbox WHERE envelope::jsonb ->> 'event_type' = ? ORDER BY id").all(locker.EVENT_TYPE))
++ .map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope));
++const history = async () => await db.prepare('SELECT subject, fingerprint, "from", "to", reason FROM locker_key_history ORDER BY id').all();
++const backups = async () => await db.prepare('SELECT subject, kind, blob FROM locker_key_backups ORDER BY kind').all();
++const notices = async () => (await db.prepare('SELECT message, category, service FROM notifications WHERE user_id = ? ORDER BY created_at').all(1)).map((n) => n.message);
++
++(async () => {
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tok = async (id, opts = {}) => signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config, opts);
++ const as = async (id, opts = {}) => ({ authorization: `Bearer ${await tok(id, opts)}` });
++ const call = (method, p, { body, headers = {} } = {}) => fetch(base + p, {
++ method, headers: { 'content-type': 'application/json', ...headers },
++ body: body === undefined ? undefined : JSON.stringify(body),
++ }).then(async (r) => ({ status: r.status, cache: r.headers.get('cache-control'), vary: r.headers.get('vary'), body: await r.json().catch(() => null) }));
++
++ // Every log line the server writes while the calls below run.
++ const logs = [];
++ const realLog = console.log; const realError = console.error; const realWarn = console.warn;
++ const spy = (...args) => { logs.push(args.map((a) => (typeof a === 'string' ? a : String(a))).join(' ')); };
++ console.log = spy; console.error = spy; console.warn = spy;
++
++ try {
++ const ann = await published();
++ const bob = await published();
++
++ // ── The bytes a person signs are the bytes we say they are ──
++ const backupsForProof = { passphrase: { format: 'locker.envelope.v1', envelope: 'abc' } };
++ assert.strictEqual(locker.canonicalBackups(backupsForProof), '{"passphrase":{"envelope":"abc","format":"locker.envelope.v1"}}',
++ 'the canonical backups sort the keys and write no spaces');
++ assert.strictEqual(await locker.backupsDigest(backupsForProof),
++ '9706d4056a8e35c24ea67c65358650550fdde6f5770372a206173009ef1d3cf8',
++ 'the digest is the hex SHA-256 of the canonical backups');
++ assert.strictEqual(await locker.keyChangeProofMessage(ANN, ann.fingerprint, backupsForProof),
++ `openvibe-locker key-change v1|${ANN}|${ann.fingerprint}|9706d4056a8e35c24ea67c65358650550fdde6f5770372a206173009ef1d3cf8`,
++ 'the proof names the subject, the new fingerprint and the backups');
++ assert.strictEqual(locker.forgetProofMessage(ANN), `openvibe-locker forget v1|${ANN}`, 'the forget proof names the subject');
++
++ // ── The keys go in; the fingerprint is ours, not the caller's ──
++ assert.strictEqual((await call('PUT', '/api/v1/me/keys', { body: ann.body })).status, 401, 'signed in only');
++ let r = await call('PUT', '/api/v1/me/keys', { headers: await as(1), body: ann.body });
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.strictEqual(r.body.fingerprint, ann.fingerprint, 'the fingerprint is computed here');
++ assert.strictEqual(r.body.subject, ANN);
++ assert.strictEqual(r.body.changed, true);
++ assert.strictEqual(r.body.reason, 'created');
++ assert.match(r.cache, /private/); assert.match(r.cache, /no-store/);
++ assert.strictEqual(r.vary, 'Authorization, Cookie');
++ assert.ok(!('backups' in r.body), 'the answer never carries a backup');
++ assert.deepStrictEqual(Object.keys(r.body).sort(), ['changed', 'changed_at', 'ecdh_public', 'fingerprint', 'reason', 'signing_public', 'subject', 'updated_at']);
++
++ // ── A bad self-signature is refused ──
++ const badSignature = { ...ann.body, self_signature: Buffer.from(crypto.randomBytes(64)).toString('base64') };
++ r = await call('PUT', '/api/v1/me/keys', { headers: await as(1), body: badSignature });
++ assert.strictEqual(r.status, 400, 'a signature that does not verify is refused');
++ assert.strictEqual(r.body.code, 'locker.bad_signature');
++
++ // ── A fingerprint that does not match the keys is refused ──
++ r = await call('PUT', '/api/v1/me/keys', { headers: await as(1), body: { ...ann.body, fingerprint: 'alpha bravo charlie delta-echo foxtrot golf hotel' } });
++ assert.strictEqual(r.status, 400, 'a fingerprint that is not the one the keys produce is refused');
++ assert.strictEqual(r.body.code, 'locker.fingerprint_mismatch');
++
++ // ── A key that is not a P-256 key is refused ──
++ r = await call('PUT', '/api/v1/me/keys', { headers: await as(1), body: { ...ann.body, ecdh_public: { kty: 'EC', crv: 'P-384', x: 'x', y: 'y' } } });
++ assert.strictEqual(r.status, 400);
++ assert.strictEqual(r.body.code, 'locker.bad_keys');
++
++ // ── A backup: the wrapped private keys, sealed with a passphrase ──
++ const backup = await backupOf(ann.priv);
++ const backupProof = await sign(ann.person, await locker.keyChangeProofBytes(ANN, ann.fingerprint, { passphrase: backup }));
++ r = await call('PUT', '/api/v1/me/keys', { headers: await as(1), body: { ...ann.body, backups: { passphrase: backup }, previous_signature: backupProof } });
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.strictEqual(r.body.changed, true, 'the backup is a change');
++ let held = await backups();
++ assert.strictEqual(held.length, 1);
++ assert.strictEqual(held[0].kind, 'passphrase');
++ assert.strictEqual(held[0].blob.format, 'locker.envelope.v1');
++ assert.strictEqual(held[0].blob.envelope, backup.envelope, 'the blob is stored as it came');
++
++ // ── An oversized blob is refused ──
++ const huge = { format: 'locker.envelope.v1', envelope: Buffer.alloc(20 * 1024, 7).toString('base64') };
++ r = await call('PUT', '/api/v1/me/keys', { headers: await as(1), body: { ...ann.body, backups: { passphrase: huge } } });
++ assert.strictEqual(r.status, 413, 'a blob bigger than 16 KB is refused');
++ assert.strictEqual(r.body.code, 'locker.backup_too_large');
++
++ // ── A blob with a low PBKDF2 count is refused ──
++ const weak = repatch(Buffer.from(backup.envelope, 'base64'), (h) => { h.slots.password.iterations = 1000; });
++ r = await call('PUT', '/api/v1/me/keys', { headers: await as(1), body: { ...ann.body, backups: { passphrase: { format: 'locker.envelope.v1', envelope: weak.toString('base64') } } } });
++ assert.strictEqual(r.status, 400, 'a blob with too few iterations is refused');
++ assert.strictEqual(r.body.code, 'locker.bad_iterations');
++
++ // ── A blob that is not a Locker envelope is refused ──
++ r = await call('PUT', '/api/v1/me/keys', { headers: await as(1), body: { ...ann.body, backups: { passphrase: { format: 'locker.envelope.v1', envelope: Buffer.from('not an envelope at all').toString('base64') } } } });
++ assert.strictEqual(r.status, 400);
++ assert.strictEqual(r.body.code, 'locker.bad_backup');
++
++ // ── A backup of a kind we do not know is refused ──
++ r = await call('PUT', '/api/v1/me/keys', { headers: await as(1), body: { ...ann.body, backups: { carrier_pigeon: backup } } });
++ assert.strictEqual(r.status, 400);
++ assert.strictEqual(r.body.code, 'locker.unknown_backup');
++
++ // ── The public keys are found by subject and by exact name ──
++ r = await call('GET', `/api/v1/people/${ANN}/keys`, { headers: await as(2) });
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.deepStrictEqual(Object.keys(r.body).sort(), ['changed_at', 'ecdh_public', 'fingerprint', 'signing_public', 'subject', 'updated_at']);
++ assert.strictEqual(r.body.fingerprint, ann.fingerprint);
++ assert.ok(r.body.changed_at, 'the last key change is there, so a sheet can warn');
++ assert.match(r.cache, /no-store/);
++
++ r = await call('GET', '/api/v1/people/@ann/keys', { headers: await as(2) });
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.strictEqual(r.body.fingerprint, ann.fingerprint);
++
++ r = await call('GET', '/api/v1/people/@ANN/keys', { headers: await as(2) });
++ assert.strictEqual(r.status, 200, 'names are not case-sensitive, but they are exact');
++
++ assert.strictEqual((await call('GET', `/api/v1/people/${ANN}/keys`)).status, 401, 'signed in only');
++ assert.strictEqual((await call('GET', '/api/v1/people/@nobody/keys', { headers: await as(2) })).status, 404, 'no such person');
++ assert.strictEqual((await call('GET', '/api/v1/people/@ann/keys/extra', { headers: await as(2) })).status, 404, 'exact names only');
++
++ // ── One person's backup is never readable by another ──
++ r = await call('GET', '/api/v1/me/keys/backup', { headers: await as(1) });
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.body.subject, ANN);
++ assert.strictEqual(r.body.backups.passphrase.envelope, backup.envelope, 'my own backup comes back');
++ assert.match(r.cache, /no-store/);
++
++ r = await call('GET', '/api/v1/me/keys/backup', { headers: await as(2) });
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(r.body.backups, {}, 'another person gets nothing of mine');
++ assert.ok(!JSON.stringify(r.body).includes(backup.envelope), 'not even a part of it');
++
++ r = await call('GET', `/api/v1/people/${ANN}/keys/backup`, { headers: await as(2) });
++ assert.strictEqual(r.status, 404, 'there is no route to another person\'s backup');
++ r = await call('GET', `/api/v1/people/${ANN}/keys`, { headers: await as(2) });
++ assert.ok(!JSON.stringify(r.body).includes(backup.envelope), 'the public keys carry no blob');
++
++ // ── The history and the event say what happened ──
++ let rows = await history();
++ assert.deepStrictEqual(rows.map((h) => [h.subject, h.reason, h['from'], h['to']]),
++ [[ANN, 'created', null, ann.fingerprint], [ANN, 'restored', ann.fingerprint, ann.fingerprint]],
++ 'the first set of keys, then the same keys with a backup');
++ let ev = await events();
++ assert.strictEqual(ev.length, 2, 'two events: the first set of keys and the backup');
++ assert.deepStrictEqual(ev[0].payload, {
++ subject: { type: 'user', id: ANN }, fingerprint: ann.fingerprint, previous_fingerprint: null,
++ reason: 'created', changed_at: ev[0].payload.changed_at,
++ });
++ assert.ok(!JSON.stringify(ev[0]).includes(backup.envelope), 'the event carries no blob');
++
++ // ── A change needs the signature of the key the person has now ──
++ const rotated = await published();
++ r = await call('PUT', '/api/v1/me/keys', { headers: await as(1), body: rotated.body });
++ assert.strictEqual(r.status, 403, 'no proof, no change');
++ assert.strictEqual(r.body.code, 'locker.proof_required');
++
++ const wrongProof = await sign(bob.person, await locker.keyChangeProofBytes(ANN, rotated.fingerprint, {}));
++ r = await call('PUT', '/api/v1/me/keys', { headers: await as(1), body: { ...rotated.body, previous_signature: wrongProof } });
++ assert.strictEqual(r.status, 403, 'a proof by another key is refused');
++ assert.strictEqual(r.body.code, 'locker.proof_required');
++
++ const otherProof = await sign(ann.person, await locker.keyChangeProofBytes(ANN, ann.fingerprint, {}));
++ r = await call('PUT', '/api/v1/me/keys', { headers: await as(1), body: { ...rotated.body, previous_signature: otherProof } });
++ assert.strictEqual(r.status, 403, 'a proof over other bytes is refused');
++ assert.strictEqual(r.body.code, 'locker.proof_required');
++
++ // Nothing changed while the proofs were refused.
++ assert.strictEqual((await history()).length, 2, 'a refused change leaves no trace');
++
++ const goodProof = await sign(ann.person, await locker.keyChangeProofBytes(ANN, rotated.fingerprint, {}));
++ r = await call('PUT', '/api/v1/me/keys', { headers: await as(1), body: { ...rotated.body, previous_signature: goodProof } });
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.strictEqual(r.body.changed, true);
++ assert.strictEqual(r.body.reason, 'rotated');
++ assert.strictEqual(r.body.fingerprint, rotated.fingerprint);
++ rows = await history();
++ assert.strictEqual(rows.length, 3);
++ assert.deepStrictEqual([rows[2].reason, rows[2]['from'], rows[2]['to']], ['rotated', ann.fingerprint, rotated.fingerprint]);
++ ev = await events();
++ assert.strictEqual(ev.length, 3);
++ assert.deepStrictEqual([ev[2].payload.reason, ev[2].payload.previous_fingerprint, ev[2].payload.fingerprint],
++ ['rotated', ann.fingerprint, rotated.fingerprint]);
++
++ // The sheet sees the change: the fingerprint and the day it changed.
++ r = await call('GET', `/api/v1/people/${ANN}/keys`, { headers: await as(2) });
++ assert.strictEqual(r.body.fingerprint, rotated.fingerprint);
++ assert.strictEqual(typeof r.body.changed_at, 'string', 'the last change is there');
++
++ // ── Every change tells the person ──
++ let said = await notices();
++ assert.strictEqual(said.length, 3, 'three changes, three notices');
++ assert.ok(said.includes(locker.NOTICES.created), 'the first set of keys is announced');
++ assert.ok(said.includes(locker.NOTICES.restored), 'the backup is announced');
++ assert.ok(said.includes(locker.NOTICES.rotated), 'the rotation is announced');
++
++ // ── A reset: the passphrase and the recovery kit are gone ──
++ const stale = { authTime: Math.floor(Date.now() / 1000) - 3600 };
++ r = await call('PUT', '/api/v1/me/keys', { headers: await as(1, stale), body: { ...rotated.body, reset: true } });
++ assert.strictEqual(r.status, 403, 'a reset needs a sign-in from the last ten minutes');
++ assert.strictEqual(r.body.code, 'locker.reauth_required');
++
++ const afterReset = await published();
++ r = await call('PUT', '/api/v1/me/keys', { headers: await as(1), body: { ...afterReset.body, reset: true } });
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.strictEqual(r.body.reason, 'reset');
++ assert.strictEqual(r.body.fingerprint, afterReset.fingerprint);
++ rows = await history();
++ assert.strictEqual(rows.length, 4);
++ assert.deepStrictEqual([rows[3].reason, rows[3]['from'], rows[3]['to']], ['reset', rotated.fingerprint, afterReset.fingerprint]);
++ ev = await events();
++ assert.strictEqual(ev.length, 4);
++ assert.deepStrictEqual([ev[3].payload.reason, ev[3].payload.previous_fingerprint], ['reset', rotated.fingerprint]);
++ said = await notices();
++ assert.ok(said.includes('Your locker was reset: things locked for your old keys cannot be opened'),
++ 'the person is told what the reset means');
++
++ // ── The lookups are rate-limited per caller ──
++ let limited = null;
++ for (let i = 0; i < 62; i++) {
++ const rr = await call('GET', `/api/v1/people/${BOB}/keys`, { headers: await as(3) });
++ if (rr.status === 429) { limited = { i, body: rr.body }; break; }
++ }
++ assert.ok(limited, 'the rate limit answers');
++ assert.strictEqual(limited.i, 60, 'sixty a minute, then the limit');
++ assert.strictEqual((await call('GET', `/api/v1/people/${ANN}/keys`, { headers: await as(2) })).status, 200, 'another caller is not affected');
++
++ // ── Forgetting the locker ──
++ r = await call('DELETE', '/api/v1/me/keys', { headers: await as(1, stale), body: { confirm: 'forget my locker' } });
++ assert.strictEqual(r.status, 403, 'forgetting needs the current key or a recent sign-in');
++ assert.strictEqual(r.body.code, 'locker.proof_required');
++
++ r = await call('DELETE', '/api/v1/me/keys', { headers: await as(1), body: { confirm: 'forget my locker' } });
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.strictEqual(r.body.forgotten, true);
++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM locker_keys').get()).n, 0);
++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM locker_key_backups').get()).n, 0);
++ const forgotten = await history();
++ assert.strictEqual(forgotten.length, 5);
++ assert.deepStrictEqual([forgotten[4].reason, forgotten[4]['from'], forgotten[4]['to']], ['forgotten', afterReset.fingerprint, null]);
++ assert.strictEqual((await events()).length, 5, 'the forgetting is announced too');
++ assert.ok((await notices()).includes(locker.NOTICES.forgotten), 'and the person is told');
++ assert.strictEqual((await call('GET', `/api/v1/people/${ANN}/keys`, { headers: await as(2) })).status, 404, 'the keys are gone');
++ r = await call('GET', '/api/v1/me/keys/backup', { headers: await as(1) });
++ assert.deepStrictEqual(r.body.backups, {}, 'the backups are gone');
++
++ // ── Forgetting with the proof of the key that is there now ──
++ const yet = await published();
++ // The proof is signed by the key that is stored now: the one the reset left behind.
++ const yetProof = await sign(afterReset.person, await locker.keyChangeProofBytes(ANN, yet.fingerprint, {}));
++ r = await call('PUT', '/api/v1/me/keys', { headers: await as(1), body: { ...yet.body, previous_signature: yetProof } });
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ const forgetProof = await sign(yet.person, locker.forgetProofBytes(ANN));
++ r = await call('DELETE', '/api/v1/me/keys', { headers: await as(1, stale), body: { confirm: 'forget my locker', previous_signature: forgetProof } });
++ assert.strictEqual(r.status, 200, 'the current key can forget the locker, even with an old session');
++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM locker_keys WHERE subject = ?').get(ANN)).n, 0);
++
++ // The confirmation text is the way in.
++ const again = await published();
++ const againProof = await sign(yet.person, await locker.keyChangeProofBytes(ANN, again.fingerprint, {}));
++ await call('PUT', '/api/v1/me/keys', { headers: await as(1), body: { ...again.body, previous_signature: againProof } });
++ r = await call('DELETE', '/api/v1/me/keys', { headers: await as(1), body: { confirm: 'yes' } });
++ assert.strictEqual(r.status, 400, 'the confirmation text must match');
++ assert.strictEqual(r.body.code, 'locker.confirm_required');
++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM locker_keys WHERE subject = ?').get(ANN)).n, 1, 'and nothing was forgotten');
++
++ // ── A write from another site is refused ──
++ r = await call('PUT', '/api/v1/me/keys', { headers: { ...(await as(1)), 'sec-fetch-site': 'cross-site' }, body: again.body });
++ assert.strictEqual(r.status, 403, 'a write another site started is refused');
++ r = await call('PUT', '/api/v1/me/keys', { headers: { ...(await as(1)), origin: 'https://evil.example' }, body: again.body });
++ assert.strictEqual(r.status, 403, 'and so is one with another origin');
++ r = await call('PUT', '/api/v1/me/keys', { headers: { ...(await as(1)), origin: 'https://someone.openvibe.tools', 'sec-fetch-site': 'same-site' }, body: again.body });
++ assert.strictEqual(r.status, 403, 'a page people host on a subdomain is not an OpenVibe site');
++ assert.strictEqual(r.body.code, 'locker.cross_site');
++ r = await call('PUT', '/api/v1/me/keys', { headers: { ...(await as(1)), origin: 'https://openvibe.community', 'sec-fetch-site': 'cross-site' }, body: again.body });
++ assert.notStrictEqual(r.body.code, 'locker.cross_site', 'an OpenVibe site (ov-locker.js on Community) may write; here only the proof is missing');
++ assert.strictEqual(r.body.code, 'locker.proof_required', 'the proof rule still applies to an OpenVibe site');
++ r = await call('DELETE', '/api/v1/me/keys', { headers: { ...(await as(1)), origin: 'https://evil.example' }, body: { confirm: 'forget my locker' } });
++ assert.strictEqual(r.status, 403, 'a forget from another site is refused');
++ assert.strictEqual(r.body.code, 'locker.cross_site');
++
++ // ── The docs name it: the API docs and llms.txt ──
++ const llms = require('../server/home/render').llmsTxt();
++ assert.match(llms, /## Locker keys/, 'llms.txt has a Locker keys section');
++ assert.ok(llms.includes('/api/v1/people/@maticus/keys'), 'llms.txt has the curl for looking a public key up');
++ assert.ok(llms.includes('never read a private key'), 'llms.txt says what Network cannot read');
++ const readme = require('fs').readFileSync(require('path').join(__dirname, '..', 'README.md'), 'utf8');
++ assert.match(readme, /## Locker keys/, 'the API docs have a Locker keys section');
++ for (const route of ['PUT /api/v1/me/keys', 'GET /api/v1/me/keys/backup', 'DELETE /api/v1/me/keys',
++ 'GET /api/v1/people/:subject/keys', 'GET /api/v1/people/@:username/keys']) {
++ assert.ok(readme.includes(route), `the API docs name ${route}`);
++ }
++ assert.ok(readme.includes('openvibe-locker key-change v1|'), 'the API docs give the bytes to sign');
++ assert.ok(readme.includes('openvibe-locker forget v1|'), 'and the bytes to forget');
++
++ // ── Nothing that is a key, a signature or a blob reaches a log line ──
++ const written = logs.join('\n');
++ assert.ok(written.length >= 0);
++ for (const secret of [ann.body.self_signature, bob.body.self_signature, backup.envelope, ann.priv.ecdh.d, ann.priv.signing.d, rotated.body.self_signature]) {
++ assert.ok(!written.includes(secret), `a secret reached the log: ${String(secret).slice(0, 12)}…`);
++ }
++ // Even an unexpected error is redacted before it is written.
++ const noisy = locker.safeMessage({ message: `the row (${backup.envelope}) is not a row` });
++ assert.ok(!noisy.includes(backup.envelope), 'an error message cannot carry a blob');
++ assert.ok(noisy.includes('[redacted]'), 'but it still says something');
++ } finally {
++ console.log = realLog; console.error = realError; console.warn = realWarn;
++ server.close();
++ }
++ console.log('locker keys: ok');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });
++})();
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 1c8293ba..51838083 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');