openvibers/openvibe.network Commits

Passkeys across related origins: publish the list and broker product logins (#850)

OP OpenVibers committed 1 days ago · eb3f27da76 · 7 files, +447 −5

Publish https://openvibe.network/.well-known/webauthn with the exact product
origins generated from the live service manifests plus reviewed aliases, and
refuse tenant/infrastructure hosts.

Broker a product-origin passkey ceremony through the product's OAuth client:
options, verify and redeem are server-to-server, bound to the exact origin,
the registered client redirect origin and a browser binding the product keeps
in its own __Host- cookie. Network verifies the signed origin and RP ID,
consumes the challenge atomically, keeps the existing user/presence/
verification/counter/lockout checks and returns a single-use result that
becomes the product's ordinary audience-scoped session. No Network session
cookie or browser-held Network token is involved.

Changes

ddiff --git a/docs/security/passkeys.md b/docs/security/passkeys.md
iindex 52b34697..b2c2dc12 100644
---- a/docs/security/passkeys.md
++++ b/docs/security/passkeys.md
@@@ -2,16 +2,28 @@
Enrollment uses the existing default-off `mfa_enrollment` owner setting and `NETWORK_MFA_PILOT=1` pilot (configured owner and `ovprobe_merge_keep`). Leave general enrollment off pending the account-screen review. Existing keys remain usable and protect the password fallback even when enrollment is off. Existing sessions and site data are unchanged.
--The relying party is **openvibe.network**, with the exact allowed origin **https://openvibe.network**. A sign-in begun on another OpenVibe site redirects to Network as before; the browser uses its Network passkey there and the existing OAuth/PKCE continuation returns the person to the originating site. Credentials are not registered independently at product domains. Local cryptographic tests simulate the Network origin; a browser ceremony requires the HTTPS Network page.
++The relying party is **openvibe.network**. On Network itself the exact allowed origin is **https://openvibe.network**. Product origins are allowed through WebAuthn Related Origin Requests (below); credentials are never registered independently at product domains, and enrollment/removal stay on Network. Local cryptographic tests simulate both origins; a browser ceremony requires the HTTPS page.
The pinned MIT packages are `@simplewebauthn/server` 14.1.0 (Node >=22) and `@simplewebauthn/browser` 13.2.2. Browser modules are served locally at `/vendor/passkeys/`; there is no third-party script or attestation service. We require discoverable credentials, user presence and user verification and request no attestation. Each account has a random opaque WebAuthn user handle. Up to ten keys have names, creation/last-use dates and backup metadata. The login page supports native conditional mediation and an explicit button.
--All new API routes under `/api/auth/passkeys` have a 30/minute address limit, JSON-only mutations, no-store responses, and same-origin browser checks. Registration and authentication challenges live five minutes and are consumed atomically. Invalid assertions count toward the existing five-attempt/five-minute account lockout. Credential counters and removals serialize on the account row. A password-pending ceremony is bound to its pending account and cannot switch to another person's credential.
++All new API routes under `/api/auth/passkeys` have a 30/minute address limit (the server-to-server related routes have a 120/minute per-client limit), JSON-only mutations, no-store responses, and same-origin browser checks. Registration and authentication challenges live five minutes and are consumed atomically. Invalid assertions count toward the existing five-attempt/five-minute account lockout. Credential counters and removals serialize on the account row. A password-pending ceremony is bound to its pending account and cannot switch to another person's credential.
Adding/removing a key requires the current password and, when any factor is already registered, a current authenticator/recovery code or a fresh passkey proof. The latter is single-use, lasts five minutes and is bound to the exact signed-in session. Adding/removing a key invalidates outstanding management challenges and remembered-browser cookies, without revoking existing sessions. Adding an authenticator to a passkey-only account also requires factor proof. First-factor registration issues ten hashed recovery codes once; existing authenticator recovery codes are retained. Password sign-in can use a passkey or an unused recovery code as its second factor. Removing the last factor removes its remaining recovery codes.
`0067_passkeys.sql` is expand-only. ADR-033 exports credential metadata, omitting public keys, opaque handles, challenges, bindings and hashes. Account deletion removes both new tables; session-wide revocation removes account-bound pending ceremonies. No codes, private keys or assertion bodies are logged. This part does not add the Part 3 session/step-up claims or notifications.
--Checks: `ov test passkeys mfa my-modules agent-schema user-owned-trust account-data`; the pipeline runs the complete suite. `test/helpers/passkey-authenticator.js` generates genuine ES256 attestations/assertions for the real verifier; it does not stub verification. Tests cover wrong origin/RP/UV/account, replay, expiration, password fallback, recovery replay, management proofs, removal, flag-off enforcement and export.
++## Related origin requests (cross-site passkeys)
++
++`GET https://openvibe.network/.well-known/webauthn` publishes `{ "origins": [...] }` with `Content-Type: application/json`. The list is generated from the live service manifests' `publicOrigin` (`exposure.publicSite: service`) plus a short reviewed alias list; customer/tenant hosts (`*.openvibe.website`, Host/Link subdomains), infrastructure hosts (`api.`, `auth.`, `ingest.`, `s3.`, `themes.`) and http/non-default ports are never listed. All approved `openvibe.*` registrable domains share the `openvibe` label; `openre.stream` and `open.shoes` are two more, inside the browsers' five-label limit. `server/webauthn-origins.js` is the single source.
++
++A participating product's own server brokers the ceremony with its registered OAuth client (confidential clients only; public apps and node principals are refused):
++
++- `POST /api/auth/passkeys/related/options` → `{ state, options }`. The exact `origin` must be both the client's registered redirect origin and on the published list, and a random browser binding (32+ URL-safe bytes, kept in the product's own `__Host-` cookie) is required. Purpose is `login` only.
++- `POST /api/auth/passkeys/related/verify` → `{ result }`. Network verifies the assertion against the exact signed `origin` and RP ID `openvibe.network`, requires user verification and the account's own discoverable user handle, consumes the challenge atomically, applies the existing counter/lockout/account-state checks, and stores a single-use result bound to that client and origin.
++- `POST /api/auth/passkeys/related/redeem` → the product's ordinary OAuth token pair (same issuer, verifier and audience rules as `/oauth/token`). The result is consumed once and cannot be redeemed by another client or origin. No Network session cookie or browser-held Network token is involved.
++
++Refusals tested in `test/passkeys-related.test.js`: unpublished origin, an origin not registered to the calling client, a registered but unpublished origin, wrong client secret/public client, wrong purpose, missing/short browser binding, wrong binding, borrowed state on another origin, expiry, challenge and result replay, concurrent redemption (one winner), wrong client/origin redemption, wrong RP, non-UV flags, wrong user handle, a Network-origin assertion in the product flow, a merged user and a pending-password ceremony for another account. The iOS/Android-style virtual authenticator is the existing software ES256 helper; a browser ceremony is verified in HTTPS Chrome with a virtual authenticator during rollout.
++
++Checks: `ov test passkeys-related passkeys mfa my-modules agent-schema user-owned-trust account-data`; the pipeline runs the complete suite. `test/helpers/passkey-authenticator.js` generates genuine ES256 attestations/assertions for the real verifier; it does not stub verification. Tests cover wrong origin/RP/UV/account, replay, expiration, password fallback, recovery replay, management proofs, removal, flag-off enforcement and export.
For Fable: account passkey registration/name, fresh-authentication controls, one-time recovery display, key list/removal, sign-in passkey button, and native conditional UI entry point. All additions use plain kit markup with no new CSS.
ddiff --git a/server/auth/passkeys-related.js b/server/auth/passkeys-related.js
nnew file mode 100644
iindex 00000000..7aaaaa8d
---- /dev/null
++++ b/server/auth/passkeys-related.js
@@@ -0,0 +1,192 @@
++'use strict';
++/**
++ * Cross-site passkeys: WebAuthn Related Origin Requests (openvibe.network/.well-known/webauthn).
++ *
++ * A person on openvibe.blog runs `navigator.credentials.get` with Network's rpID; the browser checks
++ * Network's well-known list and allows it for that exact origin. The ceremony is brokered by the
++ * product's own server, never by the browser calling Network:
++ *
++ * POST /api/auth/passkeys/related/options (OAuth client) → { state, options }
++ * POST /api/auth/passkeys/related/verify (OAuth client) → { result } one use
++ * POST /api/auth/passkeys/related/redeem (OAuth client) → tokens for the product's session
++ *
++ * The client authenticates with its registered OAuth secret. Its exact redirect origin and the
++ * published related-origin list must both contain the origin, which is never taken from a browser
++ * header. The challenge is stored with a hash of (client, origin, browser binding); the product keeps
++ * the browser binding in its own `__Host-` cookie. Network verifies the exact signed origin and RP ID,
++ * consumes the challenge atomically, and keeps the existing user/presence/verification/counter/
++ * lockout checks. The result is single-use and redeemable only by the same client and origin; it turns
++ * into the product's ordinary audience-scoped session through /oauth/token's issuer, never a Network
++ * session cookie or a browser-held Network bearer token.
++ */
++const crypto = require('crypto');
++const express = require('express');
++const rateLimit = require('express-rate-limit');
++const webauthn = require('@simplewebauthn/server');
++const mfa = require('./mfa');
++const dock = require('./dock-tokens');
++const devTokens = require('../developer/tokens');
++const origins = require('../webauthn-origins');
++
++const RP = 'openvibe.network';
++const BINDING_RE = /^[A-Za-z0-9_-]{32,128}$/;
++const hash = mfa.hash;
++const liveUser = u => u && !u.is_banned && !u.deleted_at && !u.merged_into;
++
++function sameSecret(a, b) {
++ const x = Buffer.from(String(a || ''));
++ const y = Buffer.from(String(b || ''));
++ return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
++}
++
++function credentials(req) {
++ const header = String(req.headers.authorization || '');
++ if (header.startsWith('Basic ')) {
++ let decoded;
++ try { decoded = Buffer.from(header.slice(6), 'base64').toString('utf8'); } catch { return null; }
++ const at = decoded.indexOf(':');
++ if (at < 0) return null;
++ return { id: decoded.slice(0, at), secret: decoded.slice(at + 1) };
++ }
++ return { id: req.body && req.body.client_id, secret: req.body && req.body.client_secret };
++}
++
++/** The confidential first-party OAuth client that is calling, or null. */
++async function clientFrom(req, db) {
++ const given = credentials(req);
++ if (!given || typeof given.id !== 'string' || !given.id || given.id.length > 200) return null;
++ if (devTokens.isAppClient(given.id) || /^lnk_/.test(given.id)) return null;
++ const client = await db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(given.id);
++ if (!client || dock.isPublicClient(client)) return null;
++ return sameSecret(client.client_secret, given.secret) ? client : null;
++}
++
++/** The exact https origins this client may return to (its registered redirect URIs). */
++function clientOrigins(client) {
++ let list;
++ try { list = JSON.parse(client.redirect_uris || '[]'); } catch { return []; }
++ const out = new Set();
++ for (const value of Array.isArray(list) ? list : []) {
++ try {
++ const u = new URL(String(value));
++ if (u.protocol === 'https:' && !u.username && !u.password && (!u.port || u.port === '443') && u.pathname !== '') out.add(u.origin);
++ } catch { /* not an origin */ }
++ }
++ return [...out];
++}
++
++function originAllowed(client, origin) {
++ return !!origin && origins.isApprovedOrigin(origin) && clientOrigins(client).includes(origin);
++}
++
++const challengeBinding = (clientId, origin, browserBinding) => hash(`ov-related-passkey\n${clientId}\n${origin}\n${browserBinding}`);
++const resultBinding = (clientId, origin) => hash(`ov-related-result\n${clientId}\n${origin}`);
++
++const invalid = res => res.status(401).json({ error: 'invalid_client' });
++const refused = res => res.status(401).json({ error: 'passkey_or_state_invalid' });
++
++function router() {
++ const r = express.Router();
++ // The product server is the caller; the browser never reaches these routes. Per-client, not per address: many
++ // products share Network's loopback address.
++ r.use(rateLimit({ windowMs: 60000, max: 120, standardHeaders: true, legacyHeaders: false, keyGenerator: req => String((req.body && req.body.client_id) || req.ip) }));
++
++ r.post('/options', async (req, res) => {
++ try {
++ const db = req.app.locals.db;
++ const client = await clientFrom(req, db);
++ if (!client) return invalid(res);
++ const origin = origins.originOf(req.body && req.body.origin);
++ if (!originAllowed(client, origin)) return res.status(403).json({ error: 'origin_not_allowed' });
++ if (String((req.body && req.body.purpose) || 'login') !== 'login') return res.status(400).json({ error: 'unsupported_purpose' });
++ const binding = String((req.body && req.body.binding) || '');
++ if (!BINDING_RE.test(binding)) return res.status(400).json({ error: 'binding_required' });
++ const options = await webauthn.generateAuthenticationOptions({ rpID: RP, userVerification: 'required' });
++ const state = await require('./passkeys').save(db, 'related-login', options.challenge, null, challengeBinding(client.client_id, origin, binding));
++ return res.json({ state, options });
++ } catch { return res.status(503).json({ error: 'Passkey service unavailable' }); }
++ });
++
++ r.post('/verify', async (req, res) => {
++ try {
++ const db = req.app.locals.db;
++ const client = await clientFrom(req, db);
++ if (!client) return invalid(res);
++ const body = req.body || {};
++ const origin = origins.originOf(body.origin);
++ if (!originAllowed(client, origin)) return res.status(403).json({ error: 'origin_not_allowed' });
++ const binding = String(body.binding || '');
++ if (!BINDING_RE.test(binding) || typeof body.state !== 'string' || !body.state || !body.response || typeof body.response.id !== 'string') {
++ return res.status(400).json({ error: 'invalid_request' });
++ }
++ const bind = challengeBinding(client.client_id, origin, binding);
++ const result = await db.tx(async () => {
++ const challenge = await require('./passkeys').take(db, body.state, 'related-login', bind);
++ if (!challenge) return null;
++ const key = await db.prepare('SELECT * FROM user_passkeys WHERE id = ?').get(body.response.id);
++ if (!key) return null;
++ const user = await db.prepare('SELECT * FROM users WHERE id = ? FOR UPDATE').get(key.user_id);
++ if (!liveUser(user)) return null;
++ const factor = await mfa.factor(db, user.id, true);
++ if (!factor || Number(factor.locked_until) > Date.now()) return null;
++ // Re-read after the account lock: removal and concurrent assertions serialize here.
++ const current = await db.prepare('SELECT * FROM user_passkeys WHERE id = ?').get(key.id);
++ if (!current) return null;
++ let checked;
++ try {
++ checked = await webauthn.verifyAuthenticationResponse({
++ response: body.response,
++ expectedChallenge: challenge.challenge,
++ expectedOrigin: origin, // the exact signed origin, never a header
++ expectedRPID: RP,
++ requireUserVerification: true,
++ credential: { id: current.id, publicKey: new Uint8Array(Buffer.from(current.public_key, 'base64url')), counter: Number(current.counter) },
++ });
++ } catch { checked = { verified: false }; }
++ // A discoverable sign-in must present the account's own user handle.
++ const handle = body.response.response && body.response.response.userHandle;
++ if (!checked.verified || !handle || handle !== factor.passkey_handle) {
++ const failures = Number(factor.locked_until) > 0 ? 1 : factor.failures + 1;
++ await db.prepare('UPDATE user_mfa SET failures = ?, locked_until = ? WHERE user_id = ?').run(failures, failures >= 5 ? Date.now() + 300000 : 0, user.id);
++ return null;
++ }
++ await db.prepare('UPDATE user_passkeys SET counter = ?, backed_up = ?, last_used_at = ? WHERE id = ?').run(checked.authenticationInfo.newCounter, checked.authenticationInfo.credentialBackedUp, Date.now(), key.id);
++ await db.prepare('UPDATE user_mfa SET failures = 0, locked_until = 0 WHERE user_id = ?').run(user.id);
++ return require('./passkeys').save(db, 'related-result', '', user, resultBinding(client.client_id, origin));
++ });
++ if (!result) return refused(res);
++ return res.json({ result });
++ } catch { return res.status(503).json({ error: 'Passkey service unavailable' }); }
++ });
++
++ r.post('/redeem', async (req, res) => {
++ try {
++ const db = req.app.locals.db;
++ const client = await clientFrom(req, db);
++ if (!client) return invalid(res);
++ const body = req.body || {};
++ const origin = origins.originOf(body.origin);
++ if (!originAllowed(client, origin)) return res.status(403).json({ error: 'origin_not_allowed' });
++ if (typeof body.result !== 'string' || !body.result) return res.status(400).json({ error: 'invalid_request' });
++ const user = await db.tx(async () => {
++ const challenge = await require('./passkeys').take(db, body.result, 'related-result', resultBinding(client.client_id, origin));
++ if (!challenge || !challenge.user_id) return null;
++ const row = await db.prepare('SELECT * FROM users WHERE id = ? FOR UPDATE').get(challenge.user_id);
++ if (!liveUser(row) || challenge.credential_hash !== hash(row.password_hash)) return null;
++ return row;
++ });
++ if (!user) return refused(res);
++ const oauth = require('./oauth-routes');
++ const pair = await oauth.issueTokenPair(db, req.app.locals.config, req, user, client, {}, 'profile');
++ oauth.recordLinkedService(db, user, client);
++ return res.json({
++ access_token: pair.accessToken, refresh_token: pair.refreshToken, token_type: 'Bearer', expires_in: pair.expiresIn,
++ user: { id: user.id, username: user.username, display_name: user.display_name, avatar_url: user.avatar_url, profile_color: user.profile_color },
++ });
++ } catch { return res.status(503).json({ error: 'Passkey service unavailable' }); }
++ });
++
++ return r;
++}
++
++module.exports = { router, clientOrigins, originAllowed, RP };
ddiff --git a/server/auth/passkeys.js b/server/auth/passkeys.js
iindex e5c8c237..9262eb9f 100644
---- a/server/auth/passkeys.js
++++ b/server/auth/passkeys.js
@@@ -36,7 +36,8 @@ async function authorize(db, req) {
}
function router({ requireAuth, signToken, sanitizeUser }) {
const r = express.Router();
-- r.use(rateLimit({ windowMs: 60000, max: 30, standardHeaders: true, legacyHeaders: false }));
++ // The server-to-server related routes have their own per-client limit (one address for every product).
++ r.use(rateLimit({ windowMs: 60000, max: 30, standardHeaders: true, legacyHeaders: false, skip: req => String(req.path).startsWith('/related/') }));
r.use((req, res, next) => {
res.set('Cache-Control', 'no-store');
if (req.method !== 'GET' && !req.is('application/json')) return res.status(415).json({ error: 'JSON required' });
@@@ -103,6 +104,9 @@ function router({ requireAuth, signToken, sanitizeUser }) {
res.json({ token, user: sanitizeUser(result.user) });
}
r.post('/login/options', route((req, res, db) => options(req, res, db, 'login')));
++ // Cross-site passkeys (Related Origin Requests): a product's server, authenticated as its OAuth
++ // client, gets and verifies one ceremony. Mounted before requireAuth: the client is the caller.
++ r.use('/related', require('./passkeys-related').router());
r.post('/login/verify', route((req, res, db) => verify(req, res, db, 'login')));
r.use(requireAuth);
r.get('/', route(async (req, res, db) => res.json({ available: await mfa.allowed(db, req.user), keys: (await list(db, req.user.id)).map(({ id, name, created_at, last_used_at, device_type, backed_up }) => ({ id, name, created_at, last_used_at, device_type, backed_up })) })));
@@@ -177,4 +181,4 @@ async function invalidate(db, userId) {
await db.prepare('UPDATE user_mfa SET generation = ?, pending_secret_ciphertext = NULL, pending_until = 0 WHERE user_id = ?').run(random(), userId);
for (const t of ['passkey_challenges', 'mfa_challenges', 'mfa_devices']) await db.prepare(`DELETE FROM ${t} WHERE user_id = ?`).run(userId);
}
--module.exports = { router, list, authorize };
++module.exports = { router, list, authorize, save, take, hash };
ddiff --git a/server/index.js b/server/index.js
iindex 9b59a208..04bf316a 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -1190,6 +1190,12 @@ require('./auth/app-links').mount(app);
const fedcm = require('./auth/fedcm');
const ctx = () => ({ db, publicKey, privateKey, config });
app.get('/.well-known/web-identity', fedcm.wellKnown(ctx));
++ // WebAuthn Related Origin Requests: the exact product origins allowed to use Network's passkeys.
++ app.get('/.well-known/webauthn', (_req, res) => {
++ res.set('Content-Type', 'application/json; charset=utf-8');
++ res.set('Cache-Control', 'public, max-age=300');
++ res.send(JSON.stringify({ origins: require('./webauthn-origins').origins() }));
++ });
// OpenID Connect discovery at the issuer's root (and RFC 8414's name for it): server/auth/oidc.js
require('./auth/oidc').mount(app);
app.use('/fedcm', fedcm.createFedcmRoutes(ctx));
ddiff --git a/server/public-cors.js b/server/public-cors.js
iindex 228af244..71a229b4 100644
---- a/server/public-cors.js
++++ b/server/public-cors.js
@@@ -28,6 +28,7 @@ const CONTRACT_RE = /^\/contracts\/[a-z0-9-]+(?:\/[a-z0-9-]+)?\/[a-z0-9_.-]+\.v\
function isPublicDiscoveryPath(p) {
const path = String(p || '');
if (path === '/.well-known/openvibe') return true;
++ if (path === '/.well-known/webauthn') return true; // the browser's Related Origin Requests check
if (/^\/api\/v1\/levels\/(?:batch|board|people\/[A-Za-z0-9_]{1,40})$/.test(path)) return true;
if (/^\/api\/v1\/people\/[A-Za-z0-9_]{3,24}\/card$/.test(path)) return true;
if (path === '/api/v1/changelog') return true; // the network changelog every site's "shipped" widget reads
ddiff --git a/server/webauthn-origins.js b/server/webauthn-origins.js
nnew file mode 100644
iindex 00000000..4e9dbe41
---- /dev/null
++++ b/server/webauthn-origins.js
@@@ -0,0 +1,63 @@
++'use strict';
++/**
++ * The origins a browser may use Network's passkeys from (WebAuthn Related Origin Requests,
++ * https://openvibe.network/.well-known/webauthn). The list is generated from the service manifests:
++ * a service that declares a public UI origin is an operator-owned product origin. Infrastructure
++ * hosts (api., auth., ingest., s3., themes.) and the staff aliases are not public UI origins, and a
++ * customer-controlled subdomain (a Website or Host tenant) is never added: the manifests carry the
++ * product's own host, not its tenants. A small reviewed alias list adds the operator-owned UI hosts
++ * that are product aliases rather than a manifest's publicOrigin.
++ *
++ * The rpID is openvibe.network and the credential store is unchanged. Listing an origin only lets a
++ * browser run a ceremony there; the Network still verifies the exact origin and the OAuth client's
++ * registered redirect origin before it issues or accepts a challenge.
++ */
++const contracts = require('openvibe-contracts');
++
++// Operator-owned UI aliases that are not a manifest publicOrigin (reviewed 2026-11-11).
++const ALIASES = Object.freeze([
++ 'https://openvibe.my', // Inventory's mounted account pages
++ 'https://openvibe.link', // Link's account pages
++ 'https://openvibe.party', // Chat's party rooms
++ 'https://openvibe.download', // Media Hub's download site
++ 'https://openvibe.pics', // Media Hub's pictures site
++ 'https://openvibe.homes', // Rent's homes site
++]);
++const TENANT_SUFFIX = '.openvibe.website';
++let cached = null;
++
++function originOf(value) {
++ let u;
++ try { u = new URL(String(value || '')); } catch { return null; }
++ if (u.protocol !== 'https:' || u.username || u.password || u.search || u.hash || u.pathname !== '/') return null;
++ if (u.port && u.port !== '443') return null;
++ return `https://${u.hostname}`;
++}
++
++function generatedOrigins(manifests) {
++ const out = new Set();
++ for (const m of manifests || []) {
++ const exposure = (m && m.exposure) || {};
++ if (exposure.state !== 'live' || exposure.publicSite !== 'service') continue;
++ const origin = originOf(m.publicOrigin);
++ if (!origin) continue;
++ const host = new URL(origin).hostname;
++ // Customer sites on the Website product are subdomains, and its own apex is cookie-free tenant content.
++ if (host === 'openvibe.website' || host.endsWith(TENANT_SUFFIX)) continue;
++ out.add(origin);
++ }
++ for (const alias of ALIASES) out.add(alias);
++ return [...out].sort();
++}
++
++/** Every exact https origin allowed to run a Network passkey ceremony. Frozen for the process. */
++function origins() {
++ if (!cached) cached = Object.freeze(generatedOrigins(contracts.services && contracts.services.manifests));
++ return cached;
++}
++
++function isApprovedOrigin(origin) {
++ return typeof origin === 'string' && origins().includes(origin);
++}
++
++module.exports = { origins, isApprovedOrigin, originOf, generatedOrigins };
ddiff --git a/test/passkeys-related.test.js b/test/passkeys-related.test.js
nnew file mode 100644
iindex 00000000..9eaed7dd
---- /dev/null
++++ b/test/passkeys-related.test.js
@@@ -0,0 +1,164 @@
++'use strict';
++// Cross-site passkeys (Related Origin Requests): real ES256 assertions for a product origin, the
++// published origin list, and every refusal the design names. Verification is never stubbed.
++const assert = require('assert');
++const crypto = require('crypto');
++const express = require('express');
++const bcrypt = require('bcryptjs');
++const cookieParser = require('cookie-parser');
++const { getDb } = require('../server/db/database');
++const auth = require('../server/auth/routes');
++const { authenticator } = require('./helpers/passkey-authenticator');
++const webauthnOrigins = require('../server/webauthn-origins');
++
++const basic = (id, secret) => 'Basic ' + Buffer.from(`${id}:${secret}`).toString('base64');
++
++(async () => {
++ process.env.NETWORK_MFA_KEY = crypto.randomBytes(32).toString('hex');
++ const db = getDb();
++ const pw = 'test-only-password';
++ const aliceId = 91, bobId = 92;
++ await db.prepare('INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?), (?, ?, ?)')
++ .run(aliceId, 'related_alice', bcrypt.hashSync(pw, 4), bobId, 'related_bob', bcrypt.hashSync(pw, 4));
++ 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 app = express(); app.use(express.json()); app.use(cookieParser()); Object.assign(app.locals, { db, config, ...keys }); app.use('/api/auth', auth);
++ const server = app.listen(0, '127.0.0.1'); await new Promise(r => server.on('listening', r));
++ const base = `http://127.0.0.1:${server.address().port}/api/auth`;
++ const alice = await db.prepare('SELECT * FROM users WHERE id = ?').get(aliceId);
++ const at = auth.signToken(alice, keys.privateKey, config);
++ const call = async (path, body, authHeader, cookies) => {
++ const res = await fetch(base + path, {
++ method: body === undefined ? 'GET' : 'POST',
++ headers: { 'content-type': 'application/json', ...(authHeader ? { authorization: authHeader } : {}), ...(cookies ? { cookie: cookies } : {}) },
++ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
++ });
++ return { status: res.status, body: await res.json(), cookies: res.headers.getSetCookie() };
++ };
++ const cookieOf = (r, name = '__Host-ov_passkey') => r.cookies.find(s => s.startsWith(name + '='))?.split(';')[0];
++ const related = (path, body, creds = basic('blog-related', 'blog-secret')) => call('/passkeys/related/' + path, body, creds);
++ const origin = 'https://openvibe.blog';
++ const binding = crypto.randomBytes(32).toString('base64url');
++ const reset = async (id) => db.prepare('UPDATE user_mfa SET failures = 0, locked_until = 0 WHERE user_id = ?').run(id);
++ const assertionFor = async (device, state, overrides = {}, bind = binding) => {
++ const r = await related('options', { origin, binding: bind, purpose: 'login' });
++ assert.equal(r.status, 200, 'related options');
++ const opts = r.body.options;
++ return related('verify', { origin, binding: bind, state: r.body.state, response: device.assert(opts, { origin, rp: 'openvibe.network', ...overrides }) });
++ };
++ try {
++ // ── the published list ─────────────────────────────────────
++ const list = webauthnOrigins.origins();
++ for (const want of ['https://openvibe.network', 'https://openvibe.blog', 'https://openvibe.live', 'https://openvibe.my', 'https://openre.stream', 'https://open.shoes']) {
++ assert.ok(list.includes(want), `well-known lists ${want}`);
++ }
++ for (const no of ['https://openvibe.website', 'https://maps.openvibe.tools', 'https://api.openvibe.network', 'https://s3.openvibe.media', 'https://evil.example']) {
++ assert.ok(!list.includes(no), `well-known excludes ${no}`);
++ }
++ assert.ok(list.every(o => /^https:\/\/[a-z0-9.-]+$/.test(o)), 'only exact https origins');
++ assert.ok(webauthnOrigins.isApprovedOrigin(origin));
++ const generated = webauthnOrigins.generatedOrigins([
++ { id: 'a', publicOrigin: 'https://openvibe.a', exposure: { state: 'live', publicSite: 'service' } },
++ { id: 'b', publicOrigin: 'https://openvibe.website', exposure: { state: 'live', publicSite: 'service' } },
++ { id: 'c', publicOrigin: 'https://openvibe.c', exposure: { state: 'internal', publicSite: 'service' } },
++ { id: 'd', publicOrigin: 'http://openvibe.d', exposure: { state: 'live', publicSite: 'service' } },
++ ]);
++ assert.ok(generated.includes('https://openvibe.a'));
++ for (const no of ['https://openvibe.website', 'https://openvibe.c', 'http://openvibe.d']) assert.ok(!generated.includes(no), no);
++
++ // ── fixtures: two confidential clients and one public, one unregistered origin ──
++ const addClient = (id, secret, redirect, isPublic = 0) => db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party, is_public) VALUES (?, ?, ?, ?, 1, ?) ON CONFLICT (client_id) DO UPDATE SET client_secret = EXCLUDED.client_secret, redirect_uris = EXCLUDED.redirect_uris, is_public = EXCLUDED.is_public")
++ .run(id, secret, id, JSON.stringify(redirect), isPublic);
++ await addClient('blog-related', 'blog-secret', ['https://openvibe.blog/auth/callback']);
++ await addClient('blog-other', 'other-secret', ['https://openvibe.blog/auth/callback']);
++ await addClient('live-related', 'live-secret', ['https://openvibe.live/auth/callback']);
++ await addClient('unknown-origin', 'unknown-secret', ['https://evil.example/auth/callback']);
++ await addClient('public-related', '', ['https://openvibe.blog/auth/callback'], 1);
++ await require('../server/identity/subjects').ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(bobId));
++
++ // ── enroll a real credential for Alice through the existing Network flow ──
++ await db.prepare("INSERT INTO site_settings (key, value) VALUES ('mfa_enrollment', 'all') ON CONFLICT (key) DO UPDATE SET value = 'all'").run();
++ const device = authenticator(), bobDevice = authenticator();
++ for (const [who, token, dev] of [[alice, at, device], [await db.prepare('SELECT * FROM users WHERE id = ?').get(bobId), auth.signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(bobId), keys.privateKey, config), bobDevice]]) {
++ const start = await call('/passkeys/register/options', { password: pw }, 'Bearer ' + token); assert.equal(start.status, 200);
++ const done = await call('/passkeys/register/verify', { state: start.body.state, response: dev.register(start.body.options), name: 'Related test key' }, 'Bearer ' + token);
++ assert.equal(done.status, 200, 'real registration for the related tests');
++ }
++
++ // ── a real assertion for the product origin, then its single-use redeem ──
++ const first = await related('options', { origin, binding, purpose: 'login' });
++ assert.equal(first.status, 200); assert.equal(first.body.options.rpId, 'openvibe.network');
++ const verified = await related('verify', { origin, binding, state: first.body.state, response: device.assert(first.body.options, { origin }) });
++ if (verified.status !== 200) console.error('related verify', verified.status, JSON.stringify(verified.body)); assert.equal(verified.status, 200, 'real ES256 assertion for https://openvibe.blog'); assert.match(verified.body.result, /^[A-Za-z0-9_-]{20,}$/);
++ const redeemed = await related('redeem', { origin, result: verified.body.result });
++ assert.equal(redeemed.status, 200, 'single-use result redeems'); assert.ok(redeemed.body.access_token && redeemed.body.refresh_token);
++ const claims = JSON.parse(Buffer.from(redeemed.body.access_token.split('.')[1], 'base64url').toString());
++ assert.equal(claims.sub, aliceId); assert.equal(claims.client_id, 'blog-related'); assert.ok([].concat(claims.aud).includes('openvibe.network'));
++ assert.equal(redeemed.body.user.username, 'related_alice');
++ assert.equal((await related('redeem', { origin, result: verified.body.result })).status, 401, 'result replay');
++ assert.equal((await related('verify', { origin, binding, state: first.body.state, response: device.assert(first.body.options, { origin }) })).status, 401, 'challenge replay');
++
++ // ── concurrent redemption: exactly one winner ──
++ const secondOptions = await related('options', { origin, binding });
++ const secondVerified = await related('verify', { origin, binding, state: secondOptions.body.state, response: device.assert(secondOptions.body.options, { origin }) });
++ assert.equal(secondVerified.status, 200);
++ const race = await Promise.all([1, 2].map(() => related('redeem', { origin, result: secondVerified.body.result })));
++ assert.deepEqual(race.map(r => r.status).sort(), [200, 401], 'atomic result redemption');
++
++ // ── refusals ───────────────────────────────────────────────
++ assert.equal((await related('options', { origin, binding }, basic('blog-related', 'wrong'))).status, 401, 'wrong client secret');
++ assert.equal((await related('options', { origin, binding }, basic('public-related', ''))).status, 401, 'public client refused');
++ assert.equal((await related('options', { origin: 'https://evil.example', binding })).status, 403, 'unpublished origin');
++ assert.equal((await related('options', { origin: 'https://openvibe.live', binding })).status, 403, 'origin not registered to this client');
++ assert.equal((await related('options', { origin, binding }, basic('unknown-origin', 'unknown-secret'))).status, 403, 'registered but unpublished origin');
++ assert.equal((await related('options', { origin, binding, purpose: 'reauth' })).status, 400, 'only a login ceremony');
++ assert.equal((await related('options', { origin, binding: '' })).status, 400, 'browser binding required');
++ assert.equal((await related('verify', { origin, binding: 'x'.repeat(43), state: 'nope', response: {} })).status, 400, 'malformed body refused');
++ let o = await related('options', { origin, binding });
++ assert.equal((await related('verify', { origin, binding: crypto.randomBytes(32).toString('base64url'), state: o.body.state, response: device.assert(o.body.options, { origin }) })).status, 401, 'wrong browser binding');
++ assert.equal((await related('verify', { origin: 'https://openvibe.live', binding, state: o.body.state, response: {} })).status, 403, 'borrowed state on another origin');
++ for (const bad of [{ origin, rp: 'evil.example' }, { origin, flags: 0x41 }, { origin, wrongHandle: true }]) {
++ o = await related('options', { origin, binding });
++ const refused = await related('verify', { origin, binding, state: o.body.state, response: device.assert(o.body.options, bad) });
++ assert.equal(refused.status, 401, `refused assertion ${JSON.stringify(Object.keys(bad))}`);
++ }
++ o = await related('options', { origin, binding });
++ const expired = { origin, binding, state: o.body.state, response: device.assert(o.body.options, { origin }) };
++ await db.prepare("UPDATE passkey_challenges SET expires_at = 0 WHERE purpose = 'related-login'").run();
++ assert.equal((await related('verify', expired)).status, 401, 'expired challenge');
++ const third = await related('options', { origin, binding });
++ const thirdVerified = await related('verify', { origin, binding, state: third.body.state, response: device.assert(third.body.options, { origin }) });
++ assert.equal(thirdVerified.status, 200);
++ assert.equal((await related('redeem', { origin, result: thirdVerified.body.result }, basic('blog-other', 'other-secret'))).status, 401, 'another client cannot redeem');
++ assert.equal((await related('redeem', { origin, result: thirdVerified.body.result }, basic('live-related', 'live-secret'))).status, 403, 'a client of another origin cannot even ask');
++ assert.equal((await related('redeem', { origin: 'https://openvibe.live', result: thirdVerified.body.result })).status, 403, 'another origin cannot redeem');
++
++ // ── account state: revoked/merged, and a pending password for someone else ──
++ await reset(aliceId); await reset(bobId);
++ const pending = cookieOf(await call('/login', { username: 'related_alice', password: pw }), '__Host-ov_mfa_pending');
++ assert.ok(pending, 'Alice has a pending password challenge');
++ o = await related('options', { origin, binding });
++ const bobVerified = await related('verify', { origin, binding, state: o.body.state, response: bobDevice.assert(o.body.options, { origin }) });
++ assert.equal(bobVerified.status, 200);
++ const bobRedeemed = await related('redeem', { origin, result: bobVerified.body.result });
++ assert.equal(bobRedeemed.status, 200);
++ assert.equal(JSON.parse(Buffer.from(bobRedeemed.body.access_token.split('.')[1], 'base64url').toString()).sub, bobId, 'a pending password for Alice cannot switch the passkey account');
++ await db.prepare('UPDATE users SET merged_into = ? WHERE id = ?').run(aliceId, aliceId);
++ assert.equal((await assertionFor(device)).status, 401, 'merged user refused');
++ await db.prepare('UPDATE users SET merged_into = NULL WHERE id = ?').run(aliceId);
++ await reset(aliceId);
++ const after = await assertionFor(device);
++ assert.equal(after.status, 200, 'a merged flag removed lets the account use its key again');
++
++ // The real verifier checked the signed origin: a valid Network-origin assertion is refused for this flow.
++ o = await related('options', { origin, binding });
++ const networkOrigin = await related('verify', { origin, binding, state: o.body.state, response: device.assert(o.body.options, { origin: 'https://openvibe.network' }) });
++ assert.equal(networkOrigin.status, 401, 'Network-origin assertion is not a product-origin assertion');
++
++ console.log('Related passkeys: published/read-only origins, real ES256 at https://openvibe.blog, RP/origin/UV/user-handle refusals, expiry, replay, concurrent redemption, client/origin binding, merged users and pending-password isolation passed');
++ } finally { server.close(); await globalThis.__ovNetworkTestDbClose?.(); }
++})().catch(err => {
++ console.error('Related passkey tests failed (assertion details redacted)');
++ console.error(String(err.stack).split('\n').filter(line => /^\s+at /.test(line)).slice(0, 4).join('\n'));
++ process.exitCode = 1;
++});