openvibers/openvibe.network Commits
The Locker key directory: public keys, wrapped backups and the key history (#577)
Changes
@@@ -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 codewaits 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." |++## LicenseMIT (`license` in [package.json](package.json)).nnew file mode 100644@@@ -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();@@@ -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"@@@ -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",@@@ -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 })),@@@ -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 ornnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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,++};@@@ -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.nnew file mode 100644@@@ -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); });++})();@@@ -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 twiceassert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
@@ -819,6 +819,9 @@ Accessible to users with `role = 'admin'`. All endpoints under `/api/admin/`.
@@ -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 |
| `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) |
| `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) |
| `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) |
| `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 |
| `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.
@@ -889,6 +892,134 @@ kind instead.
The event names and the payload shapes are Contracts share.v1, which is still being written; this code
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.
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
## License
MIT (`license` in [package.json](package.json)).
MIT (`license` in [package.json](package.json)).
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -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();
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.114.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"qrcode": "1.5.4",
"qrcode": "1.5.4",
@@ -1738,9 +1738,9 @@
@@ -1738,9 +1738,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.114.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"qrcode": "1.5.4",
"qrcode": "1.5.4",
@@ -493,6 +493,14 @@ function llmsTxt() {
@@ -493,6 +493,14 @@ function llmsTxt() {
{ title: 'Tool catalog (JSON)', url: 'https://openvibe.tools/api/catalog.json' },
{ title: 'Tool catalog (JSON)', url: 'https://openvibe.tools/api/catalog.json' },
{ title: 'Tool domains (JSON)', url: 'https://openvibe.network/api/domains' },
{ 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: [
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 })),
...catalog.families.filter(f => f.url).map(f => ({ title: f.name, url: f.path ? 'https://openvibe.tools' + f.path : f.url, note: f.tagline })),
@@ -719,6 +719,14 @@ require('./developer/confirmations').start(db);
@@ -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 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).
// 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 }));
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
// The consent registry (L24, 2026-10-31; contracts 0.256.0): every use of a person's content or likeness for AI or
new file mode 100644
new file mode 100644
@@ -0,0 +1,481 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -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,
+};
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
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.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
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.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
new file mode 100644
new file mode 100644
@@ -0,0 +1,399 @@
@@ -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); });
+})();
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
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');
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
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');
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');