openvibers/openvibe.network Commits
Add pilot passkeys for Network sign-in and second factors (#772)
Changes
nnew file mode 100644@@@ -0,0 +1,17 @@++# Passkeys (Part 2)++++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 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.++++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.++++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.nnew file mode 100644@@@ -0,0 +1,24 @@++-- phase: expand++ALTER TABLE user_mfa ADD COLUMN IF NOT EXISTS passkey_handle text;++CREATE TABLE IF NOT EXISTS user_passkeys (++ id text PRIMARY KEY,++ user_id bigint NOT NULL REFERENCES users(id),++ public_key text NOT NULL,++ counter bigint NOT NULL DEFAULT 0,++ name text NOT NULL,++ device_type text NOT NULL,++ backed_up boolean NOT NULL,++ created_at bigint NOT NULL,++ last_used_at bigint++);++CREATE INDEX IF NOT EXISTS user_passkeys_user ON user_passkeys(user_id);++CREATE TABLE IF NOT EXISTS passkey_challenges (++ token_hash text PRIMARY KEY,++ user_id bigint REFERENCES users(id),++ purpose text NOT NULL,++ challenge text NOT NULL,++ binding text NOT NULL,++ credential_hash text NOT NULL,++ expires_at bigint NOT NULL++);++CREATE INDEX IF NOT EXISTS passkey_challenges_user ON passkey_challenges(user_id);@@@ -9,6 +9,8 @@"version": "1.0.0","license": "MIT","dependencies": {++ "@simplewebauthn/browser": "13.2.2",++ "@simplewebauthn/server": "14.1.0","bcryptjs": "^2.4.3","cookie-parser": "^1.4.7","cors": "^2.8.5",@@@ -177,6 +179,12 @@"devOptional": true,"license": "Apache-2.0"},++ "node_modules/@hexagon/base64": {++ "version": "1.1.28",++ "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",++ "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",++ "license": "MIT"++ },"node_modules/@hono/node-server": {"version": "2.1.6","resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.6.tgz",@@@ -196,6 +204,12 @@"integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==","license": "MIT"},++ "node_modules/@levischuck/tiny-cbor": {++ "version": "0.2.11",++ "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",++ "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",++ "license": "MIT"++ },"node_modules/@modelcontextprotocol/sdk": {"version": "1.32.2","resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.2.tgz",@@@ -676,6 +690,239 @@"url": "https://paulmillr.com/funding/"}},++ "node_modules/@peculiar/asn1-android": {++ "version": "2.11.1",++ "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.11.1.tgz",++ "integrity": "sha512-AuUBRDNdWrgtnuJGXARa0Xdtq6OAD8GqezDQz6Qka7kyynMo9fzkcrtpBEHuX984uEB570BrhMZfJfkUDfpQfw==",++ "license": "MIT",++ "dependencies": {++ "@peculiar/asn1-schema": "^2.11.1",++ "asn1js": "^3.0.10",++ "tslib": "^2.8.1"++ },++ "engines": {++ "node": ">=14"++ }++ },++ "node_modules/@peculiar/asn1-asym-key": {++ "version": "2.11.1",++ "resolved": "https://registry.npmjs.org/@peculiar/asn1-asym-key/-/asn1-asym-key-2.11.1.tgz",++ "integrity": "sha512-YtXR0FbDtjHuNGvWXD4HFE7th5xYwG67O+ApDH/FZsjj5MOrWHzDvqwZ1vm6uFUJtMAkUDcF6VMPGOJ5gnLyUA==",++ "license": "MIT",++ "dependencies": {++ "@peculiar/asn1-pkcs8": "^2.11.1",++ "@peculiar/asn1-schema": "^2.11.1",++ "@peculiar/asn1-x509": "^2.11.1",++ "asn1js": "^3.0.10",++ "tslib": "^2.8.1"++ },++ "engines": {++ "node": ">=14"++ }++ },++ "node_modules/@peculiar/asn1-cms": {++ "version": "2.11.1",++ "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.11.1.tgz",++ "integrity": "sha512-Bk+oatE62dW0Tzzpfk6vdQxfGGEfvuGdWgmijnKjcv7HqZCJISs85z4zw2uDArrk+R1Oel1wts/BBl7HHrtAcA==",++ "license": "MIT",++ "dependencies": {++ "@peculiar/asn1-schema": "^2.11.1",++ "@peculiar/asn1-x509": "^2.11.1",++ "@peculiar/asn1-x509-attr": "^2.11.1",++ "asn1js": "^3.0.10",++ "tslib": "^2.8.1"++ },++ "engines": {++ "node": ">=14"++ }++ },++ "node_modules/@peculiar/asn1-csr": {++ "version": "2.11.1",++ "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.11.1.tgz",++ "integrity": "sha512-KVfbXaKng8886/tOPG55t5K1XkTkGbR4amjIJzytXWobFU5nZgw/sKnSpa1hxP17nUSsrOF/7Mt9WUYeyLsi6Q==",++ "license": "MIT",++ "dependencies": {++ "@peculiar/asn1-schema": "^2.11.1",++ "@peculiar/asn1-x509": "^2.11.1",++ "asn1js": "^3.0.10",++ "tslib": "^2.8.1"++ },++ "engines": {++ "node": ">=14"++ }++ },++ "node_modules/@peculiar/asn1-ecc": {++ "version": "2.11.1",++ "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.11.1.tgz",++ "integrity": "sha512-7MSNji77fQa/oN3WAfwagMy8EoJFg50sW4hZ1dtr0zU1BSugit0I2DGpIdPhvkNplopQhz5bogxpn1077eaamA==",++ "license": "MIT",++ "dependencies": {++ "@peculiar/asn1-schema": "^2.11.1",++ "@peculiar/asn1-x509": "^2.11.1",++ "asn1js": "^3.0.10",++ "tslib": "^2.8.1"++ },++ "engines": {++ "node": ">=14"++ }++ },++ "node_modules/@peculiar/asn1-pfx": {++ "version": "2.11.1",++ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.11.1.tgz",++ "integrity": "sha512-2SN2z8qkc2hk/GgTYA8RN54wtsCDZShg9gI2Mq1vLhdj1adcv5fhTbiEmExZUaAMqlizxiEpULQDfHM5iYtn1g==",++ "license": "MIT",++ "dependencies": {++ "@peculiar/asn1-cms": "^2.11.1",++ "@peculiar/asn1-pkcs8": "^2.11.1",++ "@peculiar/asn1-rsa": "^2.11.1",++ "@peculiar/asn1-schema": "^2.11.1",++ "asn1js": "^3.0.10",++ "tslib": "^2.8.1"++ },++ "engines": {++ "node": ">=14"++ }++ },++ "node_modules/@peculiar/asn1-pkcs8": {++ "version": "2.11.1",++ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.11.1.tgz",++ "integrity": "sha512-HqsSrnpQghRp9SM9QSmR6fa4z/BNktnKq7928+oq4qF0VAnuHZ4cyePKvcxh9fP4taBCP/WHNItlVNsYaQm2qw==",++ "license": "MIT",++ "dependencies": {++ "@peculiar/asn1-schema": "^2.11.1",++ "@peculiar/asn1-x509": "^2.11.1",++ "asn1js": "^3.0.10",++ "tslib": "^2.8.1"++ },++ "engines": {++ "node": ">=14"++ }++ },++ "node_modules/@peculiar/asn1-pkcs9": {++ "version": "2.11.1",++ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.11.1.tgz",++ "integrity": "sha512-OHFkTg8D2zYafILqFE4KPbqX8srpvd3diGDc2h2cduumawc765mOHGcuFMGvLWlL2bbCMWMmMSCtlXptsOuiPA==",++ "license": "MIT",++ "dependencies": {++ "@peculiar/asn1-cms": "^2.11.1",++ "@peculiar/asn1-pfx": "^2.11.1",++ "@peculiar/asn1-pkcs8": "^2.11.1",++ "@peculiar/asn1-schema": "^2.11.1",++ "@peculiar/asn1-x509": "^2.11.1",++ "@peculiar/asn1-x509-attr": "^2.11.1",++ "asn1js": "^3.0.10",++ "tslib": "^2.8.1"++ },++ "engines": {++ "node": ">=14"++ }++ },++ "node_modules/@peculiar/asn1-rsa": {++ "version": "2.11.1",++ "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.11.1.tgz",++ "integrity": "sha512-6KwKGjulF489CpidZqkogNgLj6oGzgC4BL2FClM8PuJQ4t5NqT4q4a6A4vrSxBIBhMnL8hGYkLOpbVw0Vq38Tw==",++ "license": "MIT",++ "dependencies": {++ "@peculiar/asn1-schema": "^2.11.1",++ "@peculiar/asn1-x509": "^2.11.1",++ "asn1js": "^3.0.10",++ "tslib": "^2.8.1"++ },++ "engines": {++ "node": ">=14"++ }++ },++ "node_modules/@peculiar/asn1-schema": {++ "version": "2.11.1",++ "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.11.1.tgz",++ "integrity": "sha512-jaU9OaCcbZUdyXx9hMETZB9xoNmxTGjOGFqd3cOwKFkpKnyKd9WxF3uqj4PvX4cyOMP9bSdHg5YyML2qtx/d/g==",++ "license": "MIT",++ "dependencies": {++ "@peculiar/utils": "^2.0.2",++ "asn1js": "^3.0.10",++ "tslib": "^2.8.1"++ },++ "engines": {++ "node": ">=14"++ }++ },++ "node_modules/@peculiar/asn1-x509": {++ "version": "2.11.1",++ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.11.1.tgz",++ "integrity": "sha512-SXU4/X6B3FquqQAN6ARI0bFDUV7+L1WlvPEPMKzEMkstd2D7UyM0r93fv241+OjdhqAKYGx5MvV3gzZMAp0APA==",++ "license": "MIT",++ "dependencies": {++ "@peculiar/asn1-schema": "^2.11.1",++ "@peculiar/utils": "^2.0.2",++ "asn1js": "^3.0.10",++ "tslib": "^2.8.1"++ },++ "engines": {++ "node": ">=14"++ }++ },++ "node_modules/@peculiar/asn1-x509-attr": {++ "version": "2.11.1",++ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.11.1.tgz",++ "integrity": "sha512-n7RKFrbTzt8pGBlZ90kmKEn1qsLTTyw7lrvHMK0xsA42kQugPk/GXYpNbEcl7DkkdVsU/one5UX7KQPfxLj8vQ==",++ "license": "MIT",++ "dependencies": {++ "@peculiar/asn1-schema": "^2.11.1",++ "@peculiar/asn1-x509": "^2.11.1",++ "asn1js": "^3.0.10",++ "tslib": "^2.8.1"++ },++ "engines": {++ "node": ">=14"++ }++ },++ "node_modules/@peculiar/asn1-x509-post-quantum": {++ "version": "2.11.1",++ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-post-quantum/-/asn1-x509-post-quantum-2.11.1.tgz",++ "integrity": "sha512-yD/pxCQ97nzwF7MGAW4kRd1WVLiMPhD39HxOnAr38DfGFwbpVhPnMbq+r6WX0XUrxn4w8ulsTdUafwVl46igLA==",++ "license": "MIT",++ "dependencies": {++ "@peculiar/asn1-asym-key": "^2.11.1",++ "@peculiar/asn1-schema": "^2.11.1",++ "@peculiar/asn1-x509": "^2.11.1",++ "asn1js": "^3.0.10",++ "tslib": "^2.8.1"++ },++ "engines": {++ "node": ">=14"++ }++ },++ "node_modules/@peculiar/utils": {++ "version": "2.0.3",++ "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",++ "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",++ "license": "MIT",++ "dependencies": {++ "tslib": "^2.8.1"++ }++ },++ "node_modules/@peculiar/x509": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.2.0.tgz",++ "integrity": "sha512-adpW58ySPQMOUPv8pTKT3RIyCFqfUFt8X05ukPdtkQToXDVr8dMtxuenik1yx8tNFTbpApWsM/Hi5SzlIx48+Q==",++ "license": "MIT",++ "dependencies": {++ "@peculiar/asn1-cms": "^2.11.0",++ "@peculiar/asn1-csr": "^2.11.0",++ "@peculiar/asn1-ecc": "^2.11.0",++ "@peculiar/asn1-pkcs9": "^2.11.0",++ "@peculiar/asn1-rsa": "^2.11.0",++ "@peculiar/asn1-schema": "^2.11.0",++ "@peculiar/asn1-x509": "^2.11.0",++ "@peculiar/asn1-x509-post-quantum": "^2.11.0",++ "pvtsutils": "^1.3.6",++ "tslib": "^2.8.1",++ "tsyringe": "^4.10.0"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },"node_modules/@sapphire/async-queue": {"version": "1.5.5","resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",@@@ -709,6 +956,33 @@"npm": ">=7.0.0"}},++ "node_modules/@simplewebauthn/browser": {++ "version": "13.2.2",++ "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz",++ "integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==",++ "license": "MIT"++ },++ "node_modules/@simplewebauthn/server": {++ "version": "14.1.0",++ "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.1.0.tgz",++ "integrity": "sha512-HPRRBR4Vzr45+RVvbmf6JuCfGdYYcSs4Pnnnp9YSY2IABa4JKnG+wxybczoB7rmdLCOMS/mbBa2ZL/Bcz58UlA==",++ "license": "MIT",++ "dependencies": {++ "@hexagon/base64": "^1.1.27",++ "@levischuck/tiny-cbor": "^0.2.2",++ "@peculiar/asn1-android": "^2.6.0",++ "@peculiar/asn1-ecc": "^2.6.1",++ "@peculiar/asn1-rsa": "^2.6.1",++ "@peculiar/asn1-schema": "^2.6.0",++ "@peculiar/asn1-x509": "^2.6.1",++ "@peculiar/asn1-x509-post-quantum": "^2.9.4",++ "@peculiar/x509": "^2.1.0",++ "reflect-metadata": "^0.2.2"++ },++ "engines": {++ "node": ">=22.0.0"++ }++ },"node_modules/@types/node": {"version": "26.2.0","resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",@@@ -840,6 +1114,20 @@"safer-buffer": "^2.1.0"}},++ "node_modules/asn1js": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.1.0.tgz",++ "integrity": "sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA==",++ "license": "BSD-3-Clause",++ "dependencies": {++ "pvtsutils": "^1.3.6",++ "pvutils": "^1.2.0",++ "tslib": "^2.8.1"++ },++ "engines": {++ "node": ">=12.0.0"++ }++ },"node_modules/bcryptjs": {"version": "2.4.3","resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",@@@ -2624,6 +2912,24 @@"url": "https://opencollective.com/express"}},++ "node_modules/pvtsutils": {++ "version": "1.3.6",++ "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",++ "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",++ "license": "MIT",++ "dependencies": {++ "tslib": "^2.8.1"++ }++ },++ "node_modules/pvutils": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz",++ "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==",++ "license": "MIT",++ "engines": {++ "node": ">=16.0.0"++ }++ },"node_modules/qrcode": {"version": "1.5.4","resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",@@@ -2723,6 +3029,12 @@"node": ">=4"}},++ "node_modules/reflect-metadata": {++ "version": "0.2.2",++ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",++ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",++ "license": "Apache-2.0"++ },"node_modules/require-directory": {"version": "2.1.1","resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",@@@ -3084,6 +3396,24 @@"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==","license": "0BSD"},++ "node_modules/tsyringe": {++ "version": "4.10.0",++ "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",++ "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",++ "license": "MIT",++ "dependencies": {++ "tslib": "^1.9.3"++ },++ "engines": {++ "node": ">= 6.0.0"++ }++ },++ "node_modules/tsyringe/node_modules/tslib": {++ "version": "1.14.1",++ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",++ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",++ "license": "0BSD"++ },"node_modules/type-is": {"version": "1.6.18","resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",@@@ -13,6 +13,8 @@},"license": "MIT","dependencies": {++ "@simplewebauthn/browser": "13.2.2",++ "@simplewebauthn/server": "14.1.0","bcryptjs": "^2.4.3","cookie-parser": "^1.4.7","cors": "^2.8.5",@@@ -1112,6 +1112,7 @@ async function continueAnonymous() {// Pending sign-in lives only in the server's short-lived HttpOnly cookie.function showMfaChallenge() {++ networkPasskeys.library().then(lib => lib.WebAuthnAbortService.cancelCeremony()).catch(() => {});const previous = document.getElementById('mfa-login');if (previous) previous.remove();const form = document.createElement('form');@@@ -1137,3 +1138,26 @@ function showMfaChallenge() {finally { button.disabled = false; }});}++++// Native conditional UI and the explicit button complete the existing SSO continuation.++async function signInWithPasskey(conditional = false) {++ const call = async (path, body) => {++ const res = await fetch(API_BASE + '/api/auth/passkeys/' + path, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });++ const data = await res.json();++ if (!res.ok) throw new Error(data.error || 'Passkey sign-in failed');++ return data;++ };++ try {++ const data = await networkPasskeys.authenticate(call, 'login', conditional);++ localStorage.setItem('ov_token', data.token); setAuthCookie(data.token); saveAccount(data.user, data.token);++ await guardScheduledDeletion(data.token, () => finishSignIn(data));++ } catch (err) {++ if (!conditional) document.getElementById('login-msg').textContent = err.message;++ }++}++if (window.PublicKeyCredential) {++ const button = document.createElement('button'); button.type = 'button'; button.className = 'ovk-btn ovk-btn--quiet'; button.textContent = 'Sign in with a passkey';++ button.onclick = () => signInWithPasskey();++ document.getElementById('login-form').after(button);++ PublicKeyCredential.isConditionalMediationAvailable?.().then(available => { if (available) signInWithPasskey(true); }).catch(() => {});++}@@@ -27,8 +27,8 @@ async function loadMfaSettings() {await loadMfaSettings();});} else {-- form('<label>Current password <input name="password" type="password" autocomplete="current-password" required></label>', async data => {-- const setup = await call('enroll', { password: data.get('password') });++ form('<label>Current password <input name="password" type="password" autocomplete="current-password" required></label><label>Recovery code (if you already have a passkey) <input name="code" autocomplete="one-time-code"></label>', async data => {++ const setup = await call('enroll', { password: data.get('password'), code: data.get('code') });form('<p>Scan this QR in your authenticator, or enter the key.</p><img alt="Authenticator setup QR"><p><code></code></p><label>Authenticator code <input name="code" inputmode="numeric" autocomplete="one-time-code" required></label>', async data => {const result = await call('confirm', { code: data.get('code') });controls.textContent = '';nnew file mode 100644@@@ -0,0 +1,46 @@++async function loadPasskeySettings() {++ const root = document.getElementById('passkey-settings');++ if (!root || isAnonSession() || !window.PublicKeyCredential) return;++ try {++ const state = await apiFetch('/api/auth/passkeys');++ if (!state.available && !state.keys.length) return;++ root.hidden = false;++ root.innerHTML = '<h3>Passkeys</h3><p role="status"></p><form><label>Current password <input name="password" type="password" autocomplete="current-password" required></label><label>Authenticator or recovery code <input name="code" autocomplete="one-time-code"></label><button type="button" data-verify class="ovk-btn ovk-btn--quiet">Verify with a passkey</button><label>Passkey name <input name="name" maxlength="80"></label><button type="submit" class="ovk-btn ovk-btn--primary">Add passkey</button></form><div data-keys></div>';++ const status = root.querySelector('[role="status"]');++ const form = root.querySelector('form');++ let proof;++ const call = (path, body) => apiFetch('/api/auth/passkeys/' + path, { method: 'POST', body: JSON.stringify(body) });++ const credentials = () => ({ password: form.elements.password.value, code: form.elements.code.value, proof });++ const clear = () => { proof = undefined; form.elements.password.value = ''; form.elements.code.value = ''; };++ root.querySelector('[data-verify]').onclick = async () => {++ try { proof = (await networkPasskeys.authenticate(call, 'reauth')).proof; status.textContent = 'Passkey verified for one change.'; }++ catch (err) { status.textContent = err.message; }++ };++ form.querySelector('[type="submit"]').hidden = !state.available;++ form.onsubmit = async e => {++ e.preventDefault(); const button = form.querySelector('[type="submit"]'); button.disabled = true;++ try {++ const result = await networkPasskeys.register(call, credentials(), form.elements.name.value);++ await loadPasskeySettings();++ if (result.recovery_codes.length) {++ const codes = document.createElement('pre'); codes.textContent = result.recovery_codes.join('\n');++ const note = document.createElement('p'); note.textContent = 'Save these recovery codes now. Each works once; they will not be shown again.';++ root.append(note, codes);++ }++ } catch (err) { status.textContent = err.message; }++ finally { clear(); button.disabled = false; }++ };++ for (const key of state.keys) {++ const row = document.createElement('p');++ row.textContent = key.name + ' — ' + (key.backed_up ? 'Synced' : 'Device') + ' — Last used: ' + (key.last_used_at ? new Date(Number(key.last_used_at)).toLocaleString() : 'Never') + ' ';++ const remove = document.createElement('button'); remove.className = 'ovk-btn ovk-btn--quiet'; remove.textContent = 'Remove';++ remove.onclick = async () => {++ remove.disabled = true;++ try { await call('remove', { ...credentials(), id: key.id }); await loadPasskeySettings(); }++ catch (err) { status.textContent = err.message; }++ finally { clear(); remove.disabled = false; }++ };++ row.append(remove); root.querySelector('[data-keys]').append(row);++ }++ } catch { /* An unavailable pilot never interrupts the account hub. */ }++}@@@ -139,6 +139,7 @@ async function loadUser() {loadLinked();loadSessions();loadMfaSettings();++ loadPasskeySettings();loadApps();loadBlocks();loadAnonInfo();nnew file mode 100644@@@ -0,0 +1,16 @@++/* Browser ceremonies stay on Network; product sites continue through central SSO. */++window.networkPasskeys = {++ library: () => import('/vendor/passkeys/index.js'),++ async authenticate(call, purpose = 'login', conditional = false) {++ const lib = await this.library();++ const optionsJSON = await call(purpose + '/options', {});++ const response = await lib.startAuthentication({ optionsJSON, useBrowserAutofill: conditional });++ return call(purpose + '/verify', { response });++ },++ async register(call, credentials, name) {++ const lib = await this.library();++ const setup = await call('register/options', credentials);++ const response = await lib.startRegistration({ optionsJSON: setup.options });++ return call('register/verify', { state: setup.state, response, name });++ },++};@@@ -76,7 +76,7 @@<form id="login-form" onsubmit="handleLogin(event)"><div class="field"><label>Username</label>-- <input type="text" name="username" placeholder="Your username" required autocomplete="username" autofocus>++ <input type="text" name="username" placeholder="Your username" required autocomplete="username webauthn" autofocus></div><div class="field"><label>Password</label>@@@ -203,6 +203,7 @@<div class="login-toast" id="toast"></div>++<script src="/js/passkeys.js" defer></script><script src="/js/login.js" defer></script><script src="/js/account-safety.js" defer></script><script src="/shared/ov-mark.js" async></script>@@@ -509,6 +509,7 @@<div class="section" id="sec-security"><div id="mfa-settings" hidden></div>++ <div id="passkey-settings" hidden></div><div class="card qr-entry" id="qr-signin-card" hidden><h3><span class="icon"><i class="fa-solid fa-mobile-screen-button"></i></span> Sign in on another device</h3><p>Show a code on this device, scan it with the device you want to sign in, then confirm the number it shows.</p>@@@ -648,6 +649,8 @@<script src="/js/my/consent.js" defer></script><script src="/js/my/labels.js" defer></script><script src="/js/my/security.js" defer></script>++<script src="/js/passkeys.js" defer></script>++<script src="/js/my/passkeys.js" defer></script><script src="/js/my/mfa.js" defer></script><script src="/js/my/approvals.js" defer></script><script src="/js/my/apps.js" defer></script>nnew file mode 100644@@@ -0,0 +1,47 @@++'use strict';++// Run on the deployment host with the existing probe credential in process.env.++// Never prints credentials, assertions, tokens or recovery codes. No database writes.++const { createRequire } = require('module');++const requireLive = createRequire('/opt/openvibe.network/package.json');++const crypto = require('crypto');++const { isoCBOR } = requireLive('@simplewebauthn/server/helpers');++const assert = require('assert');++const hash = b => crypto.createHash('sha256').update(b).digest();++const b64 = b => Buffer.from(b).toString('base64url');++(async () => {++ const password = process.env.PROBE_MERGE_KEEP_PW;++ if (!password) throw new Error('Probe credential unavailable');++ let token; const cookies = new Map(); let keyId; let recovery;++ const call = async (path, body, signed = false) => {++ const res = await fetch('https://openvibe.network/api/auth/' + path, { method: body === undefined ? 'GET' : 'POST', headers: { 'content-type': 'application/json', ...(signed ? { authorization: `Bearer ${token}` } : {}), cookie: [...cookies].map(([k,v]) => `${k}=${v}`).join('; ') }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });++ for (const value of res.headers.getSetCookie()) { const [pair] = value.split(';'); const i = pair.indexOf('='); if (pair.slice(i+1)) cookies.set(pair.slice(0,i), pair.slice(i+1)); else cookies.delete(pair.slice(0,i)); }++ return { status: res.status, body: await res.json() };++ };++ const login = () => call('login', { username: 'ovprobe_merge_keep', password });++ const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });++ const jwk = publicKey.export({ format: 'jwk' }); const id = crypto.randomBytes(32); let counter = 0; let handle;++ const data = flags => { const c = Buffer.alloc(4); c.writeUInt32BE(++counter); return Buffer.concat([hash('openvibe.network'), Buffer.from([flags]), c]); };++ const client = (type, options) => Buffer.from(JSON.stringify({ type, challenge: options.challenge, origin: 'https://openvibe.network', crossOrigin: false }));++ const assertion = options => { const authData = data(5); const c = client('webauthn.get', options); return { id: b64(id), rawId: b64(id), type: 'public-key', clientExtensionResults: {}, response: { clientDataJSON: b64(c), authenticatorData: b64(authData), userHandle: handle, signature: b64(crypto.sign('sha256', Buffer.concat([authData, hash(c)]), privateKey)) } }; };++ try {++ let r = await login(); assert.equal(r.status, 200); token = r.body.token; cookies.clear();++ const initial = await call('passkeys', undefined, true); assert.equal(initial.status, 200); assert.equal(initial.body.keys.length, 0, 'Probe already has a passkey; leave it alone');++ const mfa = await call('mfa', undefined, true); assert.equal(mfa.body.enabled, false, 'Probe has an authenticator; leave it alone');++ r = await call('passkeys/register/options', { password }, true); assert.equal(r.status, 200); const setup = r.body; handle = setup.options.user.id;++ const len = Buffer.alloc(2); len.writeUInt16BE(id.length);++ const cose = isoCBOR.encode(new Map([[1,2],[3,-7],[-1,1],[-2,Buffer.from(jwk.x,'base64url')],[-3,Buffer.from(jwk.y,'base64url')]]));++ const authData = Buffer.concat([data(0x45), Buffer.alloc(16), len, id, cose]);++ const response = { id: b64(id), rawId: b64(id), type: 'public-key', clientExtensionResults: {}, response: { clientDataJSON: b64(client('webauthn.create', setup.options)), attestationObject: b64(isoCBOR.encode(new Map([['fmt','none'],['attStmt',new Map()],['authData',authData]]))), transports: ['internal'] } };++ r = await call('passkeys/register/verify', { state: setup.state, response, name: 'Temporary production probe' }, true); assert.equal(r.status, 200); keyId = b64(id); recovery = r.body.recovery_codes;++ console.log('production passkey enrollment: 200');++ cookies.clear(); r = await call('passkeys/login/options', {}); const body = { response: assertion(r.body) }; const ceremonyCookie = cookies.get('__Host-ov_passkey');++ r = await call('passkeys/login/verify', body); assert.equal(r.status, 200); console.log('production passkey sign-in: 200');++ cookies.clear(); cookies.set('__Host-ov_passkey', ceremonyCookie); r = await call('passkeys/login/verify', body); assert.equal(r.status, 401); console.log('production assertion replay: 401');++ cookies.clear(); assert.equal((await login()).status, 202); r = await call('passkeys/login/options', {}); r = await call('passkeys/login/verify', { response: assertion(r.body) }); assert.equal(r.status, 200); console.log('production password plus passkey: 200');++ cookies.clear(); assert.equal((await login()).status, 202); assert.equal((await call('mfa/verify', { code: recovery[0] })).status, 200);++ cookies.clear(); assert.equal((await login()).status, 202); assert.equal((await call('mfa/verify', { code: recovery[0] })).status, 401); console.log('production recovery: 200 then 401 on reuse');++ } finally {++ if (keyId) { cookies.clear(); const r = await call('passkeys/remove', { password, code: recovery[1], id: keyId }, true); assert.equal(r.status, 200); console.log('production probe passkey removed: 200'); }++ }++ cookies.clear(); assert.equal((await login()).status, 200); console.log('production probe password sign-in restored: 200');++})().catch(() => { console.error('Production passkey probe failed; details redacted'); process.exitCode = 1; });@@@ -57,7 +57,7 @@ async function remembered(db, req, user, f, now) {// Returns true only when the password route must stop before issuing any session.async function challengeLogin(db, req, res, user) {const f = await factor(db, user.id);-- if (!f?.secret_ciphertext) return false;++ if (!f || (!f.secret_ciphertext && !(await require('./passkeys').list(db, user.id)).length)) return false;keys(); // Fail closed even when enrollment is disabled or encryption configuration is missing.const now = Date.now();if (await remembered(db, req, user, f, now)) return false;@@@ -78,9 +78,9 @@ async function challengeLogin(db, req, res, user) {async function consume(db, f, code, now, enrollment = false) {if (Number(f.locked_until) > now) return false;const ciphertext = enrollment ? f.pending_secret_ciphertext : f.secret_ciphertext;-- if (!ciphertext || (enrollment && Number(f.pending_until) <= now)) return false;++ if (enrollment && (!ciphertext || Number(f.pending_until) <= now)) return false;let ok = false;-- const step = validStep(open(ciphertext, f.user_id), code, now);++ const step = ciphertext ? validStep(open(ciphertext, f.user_id), code, now) : null;if (step !== null && step > Number(f.last_step)) {await db.prepare('UPDATE user_mfa SET last_step = ? WHERE user_id = ?').run(step, f.user_id);ok = true;@@@ -117,9 +117,10 @@ function router({ requireAuth, signToken, sanitizeUser }) {const user = await db.tx(async () => {const c = await db.prepare('SELECT * FROM mfa_challenges WHERE token_hash = ?').get(hash(pending));if (!c || Number(c.expires_at) <= now) return null;++ await db.prepare('SELECT id FROM users WHERE id = ? FOR UPDATE').get(c.user_id);const f = await factor(db, c.user_id, true);const u = await db.prepare('SELECT * FROM users WHERE id = ?').get(c.user_id);-- if (!f?.secret_ciphertext || f.generation !== c.generation || !u || u.is_banned || u.deleted_at || u.merged_into || hash(u.password_hash) !== c.credential_hash) return null;++ if (!f || (!f.secret_ciphertext && !(await require('./passkeys').list(db, c.user_id)).length) || f.generation !== c.generation || !u || u.is_banned || u.deleted_at || u.merged_into || hash(u.password_hash) !== c.credential_hash) return null;if (!await db.prepare('SELECT token_hash FROM mfa_challenges WHERE token_hash = ?').get(hash(pending))) return null;if (!await consume(db, f, cleanCode(req.body.code), now)) return null;await db.prepare('DELETE FROM mfa_challenges WHERE token_hash = ?').run(hash(pending));@@@ -149,6 +150,8 @@ function router({ requireAuth, signToken, sanitizeUser }) {if (typeof req.body.password !== 'string' || !bcrypt.compareSync(req.body.password, req.user.password_hash)) return res.status(401).json({ error: 'Current password required' });const secret = new OTPAuth.Secret({ size: 20 }).base32;const result = await db.tx(async () => {++ req.user = await db.prepare('SELECT * FROM users WHERE id = ? FOR UPDATE').get(req.user.id);++ if ((await require('./passkeys').list(db, req.user.id)).length && !await require('./passkeys').authorize(db, req)) return false;await db.prepare('INSERT INTO user_mfa (user_id, generation) VALUES (?, ?) ON CONFLICT (user_id) DO NOTHING').run(req.user.id, random());const f = await factor(db, req.user.id, true);if (f.secret_ciphertext || Number(f.locked_until) > Date.now()) return false;@@@ -161,12 +164,14 @@ function router({ requireAuth, signToken, sanitizeUser }) {r.post('/confirm', route(async (req, res, db) => {if (!await allowed(db, req.user)) return res.status(404).json({ error: 'Not available' });const codes = await db.tx(async () => {++ await db.prepare('SELECT id FROM users WHERE id = ? FOR UPDATE').get(req.user.id);const f = await factor(db, req.user.id, true);if (!f || f.secret_ciphertext || !await consume(db, f, cleanCode(req.body.code), Date.now(), true)) return null;const codes = Array.from({ length: 10 }, () => crypto.randomBytes(10).toString('hex').toUpperCase());await db.prepare('DELETE FROM mfa_recovery_codes WHERE user_id = ?').run(req.user.id);for (const code of codes) await db.prepare('INSERT INTO mfa_recovery_codes (user_id, code_hash) VALUES (?, ?)').run(req.user.id, await bcrypt.hash(code, 10));await db.prepare('UPDATE user_mfa SET secret_ciphertext = pending_secret_ciphertext, pending_secret_ciphertext = NULL, pending_until = 0, enabled_at = ? WHERE user_id = ?').run(Date.now(), req.user.id);++ for (const table of ['passkey_challenges', 'mfa_challenges', 'mfa_devices']) await db.prepare(`DELETE FROM ${table} WHERE user_id = ?`).run(req.user.id);return codes;});if (!codes) return res.status(401).json({ error: 'Code or enrollment invalid' });@@@ -174,9 +179,15 @@ function router({ requireAuth, signToken, sanitizeUser }) {}));r.post('/disable', route(async (req, res, db) => {const ok = await db.tx(async () => {++ await db.prepare('SELECT id FROM users WHERE id = ? FOR UPDATE').get(req.user.id);const f = await factor(db, req.user.id, true);if (!f || !await consume(db, f, cleanCode(req.body.code), Date.now())) return false;-- for (const table of ['mfa_recovery_codes', 'mfa_challenges', 'mfa_devices', 'user_mfa']) await db.prepare(`DELETE FROM ${table} WHERE user_id = ?`).run(req.user.id);++ if ((await require('./passkeys').list(db, req.user.id)).length) {++ await db.prepare('UPDATE user_mfa SET secret_ciphertext = NULL, pending_secret_ciphertext = NULL, generation = ? WHERE user_id = ?').run(random(), req.user.id);++ for (const table of ['mfa_challenges', 'mfa_devices', 'passkey_challenges']) await db.prepare(`DELETE FROM ${table} WHERE user_id = ?`).run(req.user.id);++ } else {++ for (const table of ['mfa_recovery_codes', 'mfa_challenges', 'mfa_devices', 'user_mfa']) await db.prepare(`DELETE FROM ${table} WHERE user_id = ?`).run(req.user.id);++ }return true;});res.status(ok ? 200 : 401).json(ok ? { ok: true } : { error: 'Code invalid' });@@@ -188,4 +199,4 @@ function router({ requireAuth, signToken, sanitizeUser }) {}));return r;}--module.exports = { router, challengeLogin, validStep, totp, hash };++module.exports = { router, challengeLogin, validStep, totp, hash, allowed, factor, consume, cleanCode };nnew file mode 100644@@@ -0,0 +1,180 @@++'use strict';++const crypto = require('crypto');++const express = require('express');++const bcrypt = require('bcryptjs');++const rateLimit = require('express-rate-limit');++const webauthn = require('@simplewebauthn/server');++const mfa = require('./mfa');++const RP = 'openvibe.network';++const ORIGIN = 'https://openvibe.network';++const COOKIE = '__Host-ov_passkey';++const cookie = { httpOnly: true, secure: true, sameSite: 'strict', path: '/' };++const random = () => crypto.randomBytes(32).toString('base64url');++const hash = mfa.hash;++const liveUser = u => u && !u.is_banned && !u.deleted_at && !u.merged_into;++const binding = req => req.tokenClaims?.jti || '';++async function list(db, id) { return db.prepare('SELECT * FROM user_passkeys WHERE user_id = ? ORDER BY created_at').all(id); }++async function save(db, purpose, challenge, user, bind) {++ const token = random();++ await db.prepare('DELETE FROM passkey_challenges WHERE expires_at <= ?').run(Date.now());++ await db.prepare('INSERT INTO passkey_challenges (token_hash, user_id, purpose, challenge, binding, credential_hash, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)')++ .run(hash(token), user?.id || null, purpose, challenge, bind, user ? hash(user.password_hash) : '', Date.now() + 300000);++ return token;++}++async function take(db, token, purpose, bind) {++ if (typeof token !== 'string' || token.length > 100) return null;++ return db.prepare('DELETE FROM passkey_challenges WHERE token_hash = ? AND purpose = ? AND binding = ? AND expires_at > ? RETURNING *').get(hash(token), purpose, bind, Date.now());++}++// Inside the locked account transaction. Each management proof authorizes one operation.++async function authorize(db, req) {++ if (!binding(req) || typeof req.body.password !== 'string' || !await bcrypt.compare(req.body.password, req.user.password_hash)) return false;++ const f = await mfa.factor(db, req.user.id, true);++ if (!f?.secret_ciphertext && !(await list(db, req.user.id)).length) return true;++ const proof = await take(db, req.body.proof, 'proof', binding(req));++ if (proof && proof.user_id === req.user.id && proof.credential_hash === hash(req.user.password_hash)) return true;++ return !!f && await mfa.consume(db, f, mfa.cleanCode(req.body.code), Date.now());++}++function router({ requireAuth, signToken, sanitizeUser }) {++ const r = express.Router();++ r.use(rateLimit({ windowMs: 60000, max: 30, standardHeaders: true, legacyHeaders: false }));++ 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' });++ if (req.headers.origin && req.headers.origin !== ORIGIN) return res.status(403).json({ error: 'Network origin required' });++ if (req.headers['sec-fetch-site'] === 'cross-site') return res.status(403).json({ error: 'Same-site request required' });++ next();++ });++ const route = fn => async (req, res) => { try { await fn(req, res, req.app.locals.db); } catch { res.status(503).json({ error: 'Passkey service unavailable' }); } };++ const fail = res => res.status(401).json({ error: 'Passkey or authentication state invalid' });++ async function options(req, res, db, purpose) {++ const options = await webauthn.generateAuthenticationOptions({ rpID: RP, userVerification: 'required', ...(purpose === 'reauth' ? { allowCredentials: (await list(db, req.user.id)).map(k => ({ id: k.id })) } : {}) });++ const pending = req.cookies?.['__Host-ov_mfa_pending'];++ // A password challenge is bound at creation; it cannot later switch accounts.++ const bind = purpose === 'reauth' ? binding(req) : (pending ? hash(pending) : '');++ const token = await save(db, purpose, options.challenge, purpose === 'reauth' ? req.user : null, bind);++ res.cookie(COOKIE, token, { ...cookie, maxAge: 300000 });++ res.json(options);++ }++ async function verify(req, res, db, purpose) {++ const pending = req.cookies?.['__Host-ov_mfa_pending'];++ const bind = purpose === 'reauth' ? binding(req) : (pending ? hash(pending) : '');++ const result = await db.tx(async () => {++ const token = req.cookies?.[COOKIE];++ if (typeof token !== 'string' || token.length > 100) return null;++ const c = await db.prepare('SELECT * FROM passkey_challenges WHERE token_hash = ? AND purpose = ? AND binding = ? AND expires_at > ?').get(hash(token), purpose, bind, Date.now());++ if (!c || !req.body.response || typeof req.body.response.id !== 'string') return null;++ const key = await db.prepare('SELECT * FROM user_passkeys WHERE id = ?').get(req.body.response.id);++ if (!key || (c.user_id && c.user_id !== key.user_id)) return null;++ const u = await db.prepare('SELECT * FROM users WHERE id = ? FOR UPDATE').get(key.user_id);++ if (!liveUser(u) || (c.user_id && c.credential_hash !== hash(u.password_hash))) return null;++ if (!await take(db, token, purpose, bind)) return null;++ const f = await mfa.factor(db, u.id, true);++ if (!f || Number(f.locked_until) > Date.now()) return null;++ if (bind && purpose === 'login') {++ const p = await db.prepare('SELECT * FROM mfa_challenges WHERE token_hash = ?').get(bind);++ if (!p || p.user_id !== u.id || p.generation !== f.generation || p.credential_hash !== hash(u.password_hash) || Number(p.expires_at) <= 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: req.body.response, expectedChallenge: c.challenge,++ expectedOrigin: ORIGIN, 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 }; }++ if (!checked.verified || (req.body.response.response.userHandle ? req.body.response.response.userHandle !== f.passkey_handle : purpose === 'login')) {++ const failures = Number(f.locked_until) > 0 ? 1 : f.failures + 1;++ await db.prepare('UPDATE user_mfa SET failures = ?, locked_until = ? WHERE user_id = ?').run(failures, failures >= 5 ? Date.now() + 300000 : 0, u.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(u.id);++ if (purpose === 'reauth') return { proof: await save(db, 'proof', '', u, binding(req)) };++ if (bind) await db.prepare('DELETE FROM mfa_challenges WHERE token_hash = ?').run(bind);++ return { user: u };++ });++ res.clearCookie(COOKIE, cookie);++ if (!result) return fail(res);++ if (result.proof) return res.json({ proof: result.proof });++ res.clearCookie('__Host-ov_mfa_pending', cookie);++ const token = signToken(result.user, req.app.locals.privateKey, req.app.locals.config);++ require('./session').setSessionCookies(res, token);++ res.json({ token, user: sanitizeUser(result.user) });++ }++ r.post('/login/options', route((req, res, db) => options(req, res, db, 'login')));++ 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 })) })));++ r.post('/reauth/options', route((req, res, db) => options(req, res, db, 'reauth')));++ r.post('/reauth/verify', route((req, res, db) => verify(req, res, db, 'reauth')));++ r.post('/register/options', route(async (req, res, db) => {++ if (!await mfa.allowed(db, req.user)) return res.status(404).json({ error: 'Not available' });++ const out = await db.tx(async () => {++ req.user = await db.prepare('SELECT * FROM users WHERE id = ? FOR UPDATE').get(req.user.id);++ if (!await authorize(db, req)) return null;++ const keys = await list(db, req.user.id);++ if (keys.length >= 10) return null;++ await db.prepare('INSERT INTO user_mfa (user_id, generation, passkey_handle) VALUES (?, ?, ?) ON CONFLICT (user_id) DO NOTHING').run(req.user.id, random(), random());++ await db.prepare('UPDATE user_mfa SET passkey_handle = ? WHERE user_id = ? AND passkey_handle IS NULL').run(random(), req.user.id);++ const f = await mfa.factor(db, req.user.id);++ const options = await webauthn.generateRegistrationOptions({ rpName: 'OpenVibe.Network', rpID: RP, userName: req.user.username,++ userID: new Uint8Array(Buffer.from(f.passkey_handle, 'base64url')), attestationType: 'none',++ excludeCredentials: keys.map(k => ({ id: k.id })), authenticatorSelection: { residentKey: 'required', userVerification: 'required' } });++ const state = await save(db, 'register', options.challenge, req.user, binding(req));++ return { options, state };++ });++ if (!out) return fail(res);++ res.json(out);++ }));++ r.post('/register/verify', route(async (req, res, db) => {++ if (!await mfa.allowed(db, req.user)) return res.status(404).json({ error: 'Not available' });++ const out = await db.tx(async () => {++ const u = await db.prepare('SELECT * FROM users WHERE id = ? FOR UPDATE').get(req.user.id);++ const c = await take(db, req.body.state, 'register', binding(req));++ if (!c || c.user_id !== u.id || c.credential_hash !== hash(u.password_hash) || !liveUser(u) || (await list(db, u.id)).length >= 10) return null;++ let checked;++ try {++ const client = JSON.parse(Buffer.from(req.body.response.response.clientDataJSON, 'base64url').toString());++ if (client.crossOrigin || client.topOrigin) return null;++ checked = await webauthn.verifyRegistrationResponse({ response: req.body.response, expectedChallenge: c.challenge, expectedOrigin: ORIGIN, expectedRPID: RP, requireUserVerification: true }); } catch { return null; }++ if (!checked.verified) return null;++ const info = checked.registrationInfo;++ if (await db.prepare('SELECT id FROM user_passkeys WHERE id = ?').get(info.credential.id)) return null;++ const name = typeof req.body.name === 'string' ? req.body.name.trim().slice(0, 80) : '';++ await db.prepare('INSERT INTO user_passkeys (id, user_id, public_key, counter, name, device_type, backed_up, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')++ .run(info.credential.id, u.id, Buffer.from(info.credential.publicKey).toString('base64url'), info.credential.counter, name || 'Passkey', info.credentialDeviceType, info.credentialBackedUp, Date.now());++ await db.prepare('INSERT INTO user_mfa (user_id, generation) VALUES (?, ?) ON CONFLICT (user_id) DO NOTHING').run(u.id, random());++ let codes = [];++ if (!(await db.prepare('SELECT code_hash FROM mfa_recovery_codes WHERE user_id = ?').get(u.id))) {++ codes = Array.from({ length: 10 }, () => crypto.randomBytes(10).toString('hex').toUpperCase());++ for (const code of codes) await db.prepare('INSERT INTO mfa_recovery_codes (user_id, code_hash) VALUES (?, ?)').run(u.id, await bcrypt.hash(code, 10));++ }++ await invalidate(db, u.id);++ return { ok: true, recovery_codes: codes };++ });++ if (!out) return fail(res);++ res.json(out);++ }));++ r.post('/remove', route(async (req, res, db) => {++ const ok = await db.tx(async () => {++ req.user = await db.prepare('SELECT * FROM users WHERE id = ? FOR UPDATE').get(req.user.id);++ if (!await authorize(db, req)) return false;++ const removed = await db.prepare('DELETE FROM user_passkeys WHERE id = ? AND user_id = ?').run(typeof req.body.id === 'string' ? req.body.id : '', req.user.id);++ if (!removed.changes) return false;++ await invalidate(db, req.user.id);++ if (!(await list(db, req.user.id)).length && !(await mfa.factor(db, req.user.id))?.secret_ciphertext) {++ await db.prepare('DELETE FROM mfa_recovery_codes WHERE user_id = ?').run(req.user.id);++ }++ return true;++ });++ if (!ok) return fail(res);++ res.json({ ok: true });++ }));++ return r;++}++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 };@@@ -52,6 +52,7 @@ async function revokeTokens(db, userId, { reason, actor, ctx, strict = false } =if (!user) throw new Error(`revocation: no user ${userId}`);await db.prepare(strict ? "UPDATE users SET token_valid_after = datetime(ov_now(), '+1 second') WHERE id = ?" : 'UPDATE users SET token_valid_after = ov_now() WHERE id = ?').run(user.id);await db.prepare('UPDATE user_sessions SET is_active = 0 WHERE user_id = ?').run(user.id);++ await db.prepare('DELETE FROM passkey_challenges WHERE user_id = ?').run(user.id);await db.prepare('DELETE FROM mfa_devices WHERE user_id = ?').run(user.id);await db.prepare('DELETE FROM mfa_challenges WHERE user_id = ?').run(user.id);const validAfter = iso((await db.prepare('SELECT token_valid_after AS t FROM users WHERE id = ?').get(user.id)).t);@@@ -109,6 +109,7 @@ const { makeRequireAuth, GRACE_MS: SESSION_GRACE_MS } = require('./session');// Sliding session guard: expired-but-renewable tokens are accepted and renewed (cookie + X-OV-Token).const requireAuth = makeRequireAuth(async (req) => ({ db: await getDb(req), publicKey: req.app.locals.publicKey, config: getConfig(req) }), signToken);++router.use('/passkeys', require('./passkeys').router({ requireAuth, signToken, sanitizeUser }));router.use('/mfa', require('./mfa').router({ requireAuth, signToken, sanitizeUser }));// ── Register ─────────────────────────────────────────────────@@@ -175,8 +175,8 @@ async function networkPart(db, userId) {const byUser = async (name, table, opts) => { const r = await rowsOf(db, table, 'user_id = ?', [u.id], opts); add(name, r.rows, r.truncated); };await byUser('sign_in_providers.json', 'linked_accounts');await byUser('sessions.json', 'user_sessions');-- for (const table of ['user_mfa', 'mfa_recovery_codes', 'mfa_challenges', 'mfa_devices']) {-- await byUser(`${table}.json`, table, { omit: ['generation'] });++ for (const table of ['user_mfa', 'mfa_recovery_codes', 'mfa_challenges', 'mfa_devices', 'user_passkeys', 'passkey_challenges']) {++ await byUser(`${table}.json`, table, { omit: ['generation', 'challenge', 'binding', 'public_key', 'passkey_handle'] });}await byUser('oauth_grants.json', 'oauth_tokens');await byUser('notifications.json', 'notifications');@@@ -362,7 +362,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {const inS = `(${subjects.map(() => '?').join(',')})`;const del = async (table, where, params, key = table) => { if (await tableExists(db, table)) count(key, (await db.prepare(`DELETE FROM ${table} WHERE ${where}`).run(...params)).changes); };-- for (const t of ['mfa_recovery_codes', 'mfa_challenges', 'mfa_devices', 'user_mfa', 'linked_accounts', 'user_sessions', 'oauth_tokens', 'oauth_codes', 'email_verification_tokens', 'password_reset_tokens', 'notifications',++ for (const t of ['user_passkeys', 'passkey_challenges', 'mfa_recovery_codes', 'mfa_challenges', 'mfa_devices', 'user_mfa', 'linked_accounts', 'user_sessions', 'oauth_tokens', 'oauth_codes', 'email_verification_tokens', 'password_reset_tokens', 'notifications','notification_preferences', 'notification_quiet_hours', 'notification_digest_runs', 'user_preferences', 'user_effects', 'push_subscriptions', 'user_history', 'username_history', 'user_profile_changes','ip_log', 'email_delivery_log', 'user_theme_history', 'user_theme_site_overrides']) await del(t, `user_id IN ${inU}`, userIds);await del('experience_events', `user_id IN ${inU}`, userIds);@@@ -1172,6 +1172,7 @@ app.get(['/sso/fanout', '/sso/fanout.html'], (req, res) => {// The public pages carry the look before first paint; the ones the static file server would answer by their own// file name (public/*.html) get it here, ahead of express.static below (plan L42).app.use(lookBoot.middleware());++app.use('/vendor/passkeys', express.static(path.join(path.dirname(require.resolve('@simplewebauthn/browser')), '../esm')));app.use(express.static(path.join(__dirname, '..', 'public'), { setHeaders: require('./static-headers').publicStaticHeaders }));// openvibe-shared: the OpenVibe.Shared release package.json pins, from node_modules.@@@ -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'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', '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'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', '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,34 @@++'use strict';++// A software ES256 authenticator: exercises the real verifier, never replaces it.++const crypto = require('crypto');++const { isoCBOR } = require('@simplewebauthn/server/helpers');++const hash = b => crypto.createHash('sha256').update(b).digest();++const b64 = b => Buffer.from(b).toString('base64url');++function authenticator() {++ const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });++ const jwk = publicKey.export({ format: 'jwk' });++ const id = crypto.randomBytes(32); let counter = 0; let handle;++ const cose = isoCBOR.encode(new Map([[1, 2], [3, -7], [-1, 1], [-2, Buffer.from(jwk.x, 'base64url')], [-3, Buffer.from(jwk.y, 'base64url')]]));++ const data = (flags, rp) => { const c = Buffer.alloc(4); c.writeUInt32BE(++counter); return Buffer.concat([hash(rp), Buffer.from([flags]), c]); };++ const client = (type, options, origin) => Buffer.from(JSON.stringify({ type, challenge: options.challenge, origin, crossOrigin: false }));++ return {++ id: b64(id),++ register(options, { origin = 'https://openvibe.network', rp = 'openvibe.network', flags = 0x45 } = {}) {++ handle = options.user.id;++ const length = Buffer.alloc(2); length.writeUInt16BE(id.length);++ const authData = Buffer.concat([data(flags, rp), Buffer.alloc(16), length, id, cose]);++ return { id: b64(id), rawId: b64(id), type: 'public-key', clientExtensionResults: {}, response: {++ clientDataJSON: b64(client('webauthn.create', options, origin)),++ attestationObject: b64(isoCBOR.encode(new Map([['fmt', 'none'], ['attStmt', new Map()], ['authData', authData]]))), transports: ['internal'],++ } };++ },++ assert(options, { origin = 'https://openvibe.network', rp = 'openvibe.network', flags = 5, wrongHandle = false } = {}) {++ const authData = data(flags, rp); const clientData = client('webauthn.get', options, origin);++ return { id: b64(id), rawId: b64(id), type: 'public-key', clientExtensionResults: {}, response: {++ clientDataJSON: b64(clientData), authenticatorData: b64(authData), userHandle: wrongHandle ? b64('another account') : handle,++ signature: b64(crypto.sign('sha256', Buffer.concat([authData, hash(clientData)]), privateKey)),++ } };++ },++ };++}++module.exports = { authenticator };@@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) asconst files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();// The module map, in load order (public/js/my/…).--const modules = ['core.js', 'tools.js', 'today.js', 'machines.js', 'themes.js', 'profile.js', 'emails.js', 'consent.js', 'labels.js', 'security.js', 'mfa.js', 'approvals.js', 'apps.js', 'receipts.js', 'boot.js'];++const modules = ['core.js', 'tools.js', 'today.js', 'machines.js', 'themes.js', 'profile.js', 'emails.js', 'consent.js', 'labels.js', 'security.js', 'passkeys.js', 'mfa.js', 'approvals.js', 'apps.js', 'receipts.js', 'boot.js'];assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];const loaded = tags.map((m) => m[2]);nnew file mode 100644@@@ -0,0 +1,132 @@++'use strict';++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 accountData = require('../server/identity/account-data');++(async () => {++ process.env.NETWORK_MFA_KEY = crypto.randomBytes(32).toString('hex');++ const db = getDb(); const pw = 'test-only-password';++ await db.prepare('INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?), (?, ?, ?)').run(71, 'passkey_alice', bcrypt.hashSync(pw, 4), 72, 'passkey_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`;++ await require('../server/identity/subjects').ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = 72').get());++ const alice = await db.prepare('SELECT * FROM users WHERE id = 71').get(); const bob = await db.prepare('SELECT * FROM users WHERE id = 72').get();++ const at = auth.signToken(alice, keys.privateKey, config); const bt = auth.signToken(bob, keys.privateKey, config);++ const call = async (path, body, token, cookies) => {++ const res = await fetch(base + path, { method: body === undefined ? 'GET' : 'POST', headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}), ...(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 login = () => call('/login', { username: alice.username, password: pw });++ // Keep each phase below the production per-minute limiter without weakening middleware.++ const realNow = Date.now; let now = realNow(); Date.now = () => now;++ const next = () => { now += 61000; };++ const enroll = async (device, token = at, credentials = {}) => {++ const r = await call('/passkeys/register/options', { password: pw, ...credentials }, token); assert.equal(r.status, 200);++ const payload = { state: r.body.state, response: device.register(r.body.options), name: 'Test passkey' };++ const result = await call('/passkeys/register/verify', payload, token);++ if (result.status === 200) assert.equal((await call('/passkeys/register/verify', payload, token)).status, 401, 'registration state is single use');++ return result;++ };++ const assertion = async (device, overrides = {}, pending = '') => {++ const r = await call('/passkeys/login/options', {}, null, pending); assert.equal(r.status, 200);++ const body = { response: device.assert(r.body, overrides) }; const cookies = [cookieOf(r), pending].filter(Boolean).join('; ');++ return { r: await call('/passkeys/login/verify', body, null, cookies), body, cookies };++ };++ try {++ assert.equal((await login()).status, 200);++ assert.equal((await call('/passkeys/register/options', { password: pw }, at)).status, 404);++ await db.prepare("UPDATE site_settings SET value = 'all' WHERE key = 'mfa_enrollment'").run();++ assert.equal((await call('/passkeys/register/options', { password: 'wrong' }, at)).status, 401);++ const device = authenticator();++ let start = await call('/passkeys/register/options', { password: pw }, at);++ assert.equal(start.body.options.rp.id, 'openvibe.network'); assert.equal(start.body.options.attestation, 'none'); assert.equal(start.body.options.authenticatorSelection.userVerification, 'required');++ let payload = { state: start.body.state, response: device.register(start.body.options) };++ assert.equal((await call('/passkeys/register/verify', payload, bt)).status, 401, 'other account');++ await db.prepare('UPDATE passkey_challenges SET expires_at = 0').run();++ assert.equal((await call('/passkeys/register/verify', payload, at)).status, 401, 'expired');++ for (const invalid of [{ origin: 'https://evil.example' }, { rp: 'evil.example' }, { flags: 0x41 }]) {++ start = await call('/passkeys/register/options', { password: pw }, at);++ assert.equal((await call('/passkeys/register/verify', { state: start.body.state, response: device.register(start.body.options, invalid) }, at)).status, 401);++ }++ next();++ const enrolled = await enroll(device); assert.equal(enrolled.status, 200); const codes = enrolled.body.recovery_codes; assert.equal(codes.length, 10);++ assert.equal((await login()).status, 202, 'passkey protects password fallback');++ assert.equal((await call('/mfa/enroll', { password: pw }, at)).status, 409, 'adding TOTP also requires existing factor');++ assert.equal((await call('/passkeys/register/options', { password: pw }, at)).status, 401, 'existing factor required');++ start = await call('/passkeys/login/options', {});++ payload = { response: device.assert(start.body) };++ const race = await Promise.all([call('/passkeys/login/verify', payload, null, cookieOf(start)), call('/passkeys/login/verify', payload, null, cookieOf(start))]);++ assert.deepEqual(race.map(r => r.status).sort(), [200, 401], 'atomic assertion consumption');++ let result = await assertion(device); assert.equal(result.r.status, 200, 'real ES256 assertion'); assert.ok(result.r.body.token);++ assert.equal((await call('/passkeys/login/verify', result.body, null, result.cookies)).status, 401, 'replay');++ for (const invalid of [{ origin: 'https://evil.example' }, { rp: 'evil.example' }, { flags: 1 }, { wrongHandle: true }]) {++ assert.equal((await assertion(device, invalid)).r.status, 401);++ }++ assert.equal((await assertion(device, { flags: 1 })).r.status, 401, 'fifth failure');++ assert.equal((await assertion(device)).r.status, 401, 'valid assertion waits during lockout');++ assert.equal((await login()).status, 429, 'account lockout spans password and passkeys');++ now += 301000;++ next();++ start = await call('/passkeys/login/options', {}); payload = { response: device.assert(start.body) };++ await db.prepare('UPDATE passkey_challenges SET expires_at = 0').run();++ assert.equal((await call('/passkeys/login/verify', payload, null, cookieOf(start))).status, 401);++ // A valid assertion resets the shared failed-factor count.++ assert.equal((await assertion(device)).r.status, 200);++ const bobDevice = authenticator(); assert.equal((await enroll(bobDevice, bt)).status, 200);++ let pending = cookieOf(await login(), '__Host-ov_mfa_pending');++ assert.equal((await assertion(bobDevice, {}, pending)).r.status, 401, 'pending account cannot switch');++ assert.equal((await assertion(device, {}, pending)).r.status, 200, 'passkey second factor');++ assert.equal((await call('/mfa/verify', { code: codes[0] }, null, pending)).status, 401, 'pending consumed');++ pending = cookieOf(await login(), '__Host-ov_mfa_pending');++ assert.equal((await call('/mfa/verify', { code: codes[0] }, null, pending)).status, 200, 'passkey-only recovery');++ pending = cookieOf(await login(), '__Host-ov_mfa_pending');++ assert.equal((await call('/mfa/verify', { code: codes[0] }, null, pending)).status, 401, 'reused recovery');++ next();++ start = await call('/passkeys/reauth/options', {}, at);++ let reauth = await call('/passkeys/reauth/verify', { response: device.assert(start.body) }, at, cookieOf(start)); assert.equal(reauth.status, 200);++ assert.equal((await call('/passkeys/reauth/verify', { response: device.assert(start.body) }, at, cookieOf(start))).status, 401, 'reauth state is single use');++ const anotherSession = auth.signToken(alice, keys.privateKey, config);++ assert.equal((await call('/passkeys/remove', { password: pw, proof: reauth.body.proof, id: device.id }, anotherSession)).status, 401, 'proof bound to exact session');++ const second = authenticator(); assert.equal((await enroll(second, at, { proof: reauth.body.proof })).status, 200);++ assert.equal((await call('/passkeys/remove', { password: pw, proof: reauth.body.proof, id: second.id }, at)).status, 401, 'proof used once');++ assert.equal((await call('/passkeys/remove', { password: pw, code: codes[1], id: device.id }, bt)).status, 401, 'other account cannot remove');++ next();++ start = await call('/passkeys/reauth/options', {}, at);++ const expiredResponse = { response: device.assert(start.body) };++ await db.prepare('UPDATE passkey_challenges SET expires_at = 0').run();++ assert.equal((await call('/passkeys/reauth/verify', expiredResponse, at, cookieOf(start))).status, 401, 'reauth expiry');++ start = await call('/passkeys/reauth/options', {}, at);++ reauth = await call('/passkeys/reauth/verify', { response: device.assert(start.body) }, at, cookieOf(start));++ assert.equal(reauth.status, 200);++ await db.prepare('UPDATE passkey_challenges SET expires_at = 0').run();++ assert.equal((await call('/passkeys/remove', { password: pw, proof: reauth.body.proof, id: device.id }, at)).status, 401, 'expired management proof');++ assert.equal((await call('/passkeys', undefined, bt)).body.keys.length, 1, 'list only includes own keys');++ const exported = await accountData.networkPart(db, alice.id);++ assert.ok(exported.files.some(f => f.name === 'user_passkeys.json')); assert.ok(exported.files.some(f => f.name === 'passkey_challenges.json'));++ assert.ok(!JSON.stringify(exported).includes(codes[2])); assert.ok(!JSON.stringify(exported).includes('public_key'));++ await db.prepare("UPDATE site_settings SET value = 'off' WHERE key = 'mfa_enrollment'").run();++ assert.equal((await assertion(device)).r.status, 200, 'existing keys work with flag off');++ assert.equal((await login()).status, 202);++ assert.equal((await call('/passkeys/remove', { password: pw, code: codes[1], id: device.id }, at)).status, 200);++ assert.equal((await assertion(device)).r.status, 401, 'removed credential refused');++ assert.equal((await call('/passkeys/remove', { password: pw, code: codes[2], id: second.id }, at)).status, 200);++ assert.equal((await login()).status, 200, 'last key removed');++ next();++ // The real ADR-033 erasure path removes credentials and pending account-bound states.++ await call('/passkeys/reauth/options', {}, bt);++ const deletion = await accountData.scheduleDeletion(db, bob, { auth_time: Math.floor(now / 1000) }, bob.username);++ await accountData.erase(db, await db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(deletion.body.deletion_id));++ for (const table of ['user_passkeys', 'passkey_challenges']) assert.equal((await db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE user_id = ?`).get(bob.id)).n, 0);++ assert.equal((await assertion(bobDevice)).r.status, 401, 'deleted account key refused');++ console.log('Passkeys: real ES256 registration/sign-in, origin/RP/UV, expiry/replay, account isolation, second factor, one-use recovery/proof, removal, export and flag-off checks passed');++ } finally { Date.now = realNow; server.close(); await globalThis.__ovNetworkTestDbClose?.(); }++})().catch(err => { console.error('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; });@@@ -56,7 +56,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'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', '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'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand']]);assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+# Passkeys (Part 2)
+
+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 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.
+
+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.
+
+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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+-- phase: expand
+ALTER TABLE user_mfa ADD COLUMN IF NOT EXISTS passkey_handle text;
+CREATE TABLE IF NOT EXISTS user_passkeys (
+ id text PRIMARY KEY,
+ user_id bigint NOT NULL REFERENCES users(id),
+ public_key text NOT NULL,
+ counter bigint NOT NULL DEFAULT 0,
+ name text NOT NULL,
+ device_type text NOT NULL,
+ backed_up boolean NOT NULL,
+ created_at bigint NOT NULL,
+ last_used_at bigint
+);
+CREATE INDEX IF NOT EXISTS user_passkeys_user ON user_passkeys(user_id);
+CREATE TABLE IF NOT EXISTS passkey_challenges (
+ token_hash text PRIMARY KEY,
+ user_id bigint REFERENCES users(id),
+ purpose text NOT NULL,
+ challenge text NOT NULL,
+ binding text NOT NULL,
+ credential_hash text NOT NULL,
+ expires_at bigint NOT NULL
+);
+CREATE INDEX IF NOT EXISTS passkey_challenges_user ON passkey_challenges(user_id);
@@ -9,6 +9,8 @@
@@ -9,6 +9,8 @@
"version": "1.0.0",
"version": "1.0.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
+ "@simplewebauthn/browser": "13.2.2",
+ "@simplewebauthn/server": "14.1.0",
"bcryptjs": "^2.4.3",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cors": "^2.8.5",
@@ -177,6 +179,12 @@
@@ -177,6 +179,12 @@
"devOptional": true,
"devOptional": true,
"license": "Apache-2.0"
"license": "Apache-2.0"
},
},
+ "node_modules/@hexagon/base64": {
+ "version": "1.1.28",
+ "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
+ "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
+ "license": "MIT"
+ },
"node_modules/@hono/node-server": {
"node_modules/@hono/node-server": {
"version": "2.1.6",
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.6.tgz",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.6.tgz",
@@ -196,6 +204,12 @@
@@ -196,6 +204,12 @@
"integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
"integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/@levischuck/tiny-cbor": {
+ "version": "0.2.11",
+ "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
+ "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
+ "license": "MIT"
+ },
"node_modules/@modelcontextprotocol/sdk": {
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.32.2",
"version": "1.32.2",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.2.tgz",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.2.tgz",
@@ -676,6 +690,239 @@
@@ -676,6 +690,239 @@
"url": "https://paulmillr.com/funding/"
"url": "https://paulmillr.com/funding/"
}
}
},
},
+ "node_modules/@peculiar/asn1-android": {
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.11.1.tgz",
+ "integrity": "sha512-AuUBRDNdWrgtnuJGXARa0Xdtq6OAD8GqezDQz6Qka7kyynMo9fzkcrtpBEHuX984uEB570BrhMZfJfkUDfpQfw==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.11.1",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-asym-key": {
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-asym-key/-/asn1-asym-key-2.11.1.tgz",
+ "integrity": "sha512-YtXR0FbDtjHuNGvWXD4HFE7th5xYwG67O+ApDH/FZsjj5MOrWHzDvqwZ1vm6uFUJtMAkUDcF6VMPGOJ5gnLyUA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-pkcs8": "^2.11.1",
+ "@peculiar/asn1-schema": "^2.11.1",
+ "@peculiar/asn1-x509": "^2.11.1",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-cms": {
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.11.1.tgz",
+ "integrity": "sha512-Bk+oatE62dW0Tzzpfk6vdQxfGGEfvuGdWgmijnKjcv7HqZCJISs85z4zw2uDArrk+R1Oel1wts/BBl7HHrtAcA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.11.1",
+ "@peculiar/asn1-x509": "^2.11.1",
+ "@peculiar/asn1-x509-attr": "^2.11.1",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-csr": {
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.11.1.tgz",
+ "integrity": "sha512-KVfbXaKng8886/tOPG55t5K1XkTkGbR4amjIJzytXWobFU5nZgw/sKnSpa1hxP17nUSsrOF/7Mt9WUYeyLsi6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.11.1",
+ "@peculiar/asn1-x509": "^2.11.1",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-ecc": {
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.11.1.tgz",
+ "integrity": "sha512-7MSNji77fQa/oN3WAfwagMy8EoJFg50sW4hZ1dtr0zU1BSugit0I2DGpIdPhvkNplopQhz5bogxpn1077eaamA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.11.1",
+ "@peculiar/asn1-x509": "^2.11.1",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-pfx": {
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.11.1.tgz",
+ "integrity": "sha512-2SN2z8qkc2hk/GgTYA8RN54wtsCDZShg9gI2Mq1vLhdj1adcv5fhTbiEmExZUaAMqlizxiEpULQDfHM5iYtn1g==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-cms": "^2.11.1",
+ "@peculiar/asn1-pkcs8": "^2.11.1",
+ "@peculiar/asn1-rsa": "^2.11.1",
+ "@peculiar/asn1-schema": "^2.11.1",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-pkcs8": {
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.11.1.tgz",
+ "integrity": "sha512-HqsSrnpQghRp9SM9QSmR6fa4z/BNktnKq7928+oq4qF0VAnuHZ4cyePKvcxh9fP4taBCP/WHNItlVNsYaQm2qw==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.11.1",
+ "@peculiar/asn1-x509": "^2.11.1",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-pkcs9": {
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.11.1.tgz",
+ "integrity": "sha512-OHFkTg8D2zYafILqFE4KPbqX8srpvd3diGDc2h2cduumawc765mOHGcuFMGvLWlL2bbCMWMmMSCtlXptsOuiPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-cms": "^2.11.1",
+ "@peculiar/asn1-pfx": "^2.11.1",
+ "@peculiar/asn1-pkcs8": "^2.11.1",
+ "@peculiar/asn1-schema": "^2.11.1",
+ "@peculiar/asn1-x509": "^2.11.1",
+ "@peculiar/asn1-x509-attr": "^2.11.1",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-rsa": {
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.11.1.tgz",
+ "integrity": "sha512-6KwKGjulF489CpidZqkogNgLj6oGzgC4BL2FClM8PuJQ4t5NqT4q4a6A4vrSxBIBhMnL8hGYkLOpbVw0Vq38Tw==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.11.1",
+ "@peculiar/asn1-x509": "^2.11.1",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-schema": {
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.11.1.tgz",
+ "integrity": "sha512-jaU9OaCcbZUdyXx9hMETZB9xoNmxTGjOGFqd3cOwKFkpKnyKd9WxF3uqj4PvX4cyOMP9bSdHg5YyML2qtx/d/g==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/utils": "^2.0.2",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-x509": {
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.11.1.tgz",
+ "integrity": "sha512-SXU4/X6B3FquqQAN6ARI0bFDUV7+L1WlvPEPMKzEMkstd2D7UyM0r93fv241+OjdhqAKYGx5MvV3gzZMAp0APA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.11.1",
+ "@peculiar/utils": "^2.0.2",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-x509-attr": {
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.11.1.tgz",
+ "integrity": "sha512-n7RKFrbTzt8pGBlZ90kmKEn1qsLTTyw7lrvHMK0xsA42kQugPk/GXYpNbEcl7DkkdVsU/one5UX7KQPfxLj8vQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.11.1",
+ "@peculiar/asn1-x509": "^2.11.1",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-x509-post-quantum": {
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-post-quantum/-/asn1-x509-post-quantum-2.11.1.tgz",
+ "integrity": "sha512-yD/pxCQ97nzwF7MGAW4kRd1WVLiMPhD39HxOnAr38DfGFwbpVhPnMbq+r6WX0XUrxn4w8ulsTdUafwVl46igLA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-asym-key": "^2.11.1",
+ "@peculiar/asn1-schema": "^2.11.1",
+ "@peculiar/asn1-x509": "^2.11.1",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/utils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
+ "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/x509": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.2.0.tgz",
+ "integrity": "sha512-adpW58ySPQMOUPv8pTKT3RIyCFqfUFt8X05ukPdtkQToXDVr8dMtxuenik1yx8tNFTbpApWsM/Hi5SzlIx48+Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-cms": "^2.11.0",
+ "@peculiar/asn1-csr": "^2.11.0",
+ "@peculiar/asn1-ecc": "^2.11.0",
+ "@peculiar/asn1-pkcs9": "^2.11.0",
+ "@peculiar/asn1-rsa": "^2.11.0",
+ "@peculiar/asn1-schema": "^2.11.0",
+ "@peculiar/asn1-x509": "^2.11.0",
+ "@peculiar/asn1-x509-post-quantum": "^2.11.0",
+ "pvtsutils": "^1.3.6",
+ "tslib": "^2.8.1",
+ "tsyringe": "^4.10.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@sapphire/async-queue": {
"node_modules/@sapphire/async-queue": {
"version": "1.5.5",
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
@@ -709,6 +956,33 @@
@@ -709,6 +956,33 @@
"npm": ">=7.0.0"
"npm": ">=7.0.0"
}
}
},
},
+ "node_modules/@simplewebauthn/browser": {
+ "version": "13.2.2",
+ "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz",
+ "integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==",
+ "license": "MIT"
+ },
+ "node_modules/@simplewebauthn/server": {
+ "version": "14.1.0",
+ "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.1.0.tgz",
+ "integrity": "sha512-HPRRBR4Vzr45+RVvbmf6JuCfGdYYcSs4Pnnnp9YSY2IABa4JKnG+wxybczoB7rmdLCOMS/mbBa2ZL/Bcz58UlA==",
+ "license": "MIT",
+ "dependencies": {
+ "@hexagon/base64": "^1.1.27",
+ "@levischuck/tiny-cbor": "^0.2.2",
+ "@peculiar/asn1-android": "^2.6.0",
+ "@peculiar/asn1-ecc": "^2.6.1",
+ "@peculiar/asn1-rsa": "^2.6.1",
+ "@peculiar/asn1-schema": "^2.6.0",
+ "@peculiar/asn1-x509": "^2.6.1",
+ "@peculiar/asn1-x509-post-quantum": "^2.9.4",
+ "@peculiar/x509": "^2.1.0",
+ "reflect-metadata": "^0.2.2"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
"node_modules/@types/node": {
"node_modules/@types/node": {
"version": "26.2.0",
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
@@ -840,6 +1114,20 @@
@@ -840,6 +1114,20 @@
"safer-buffer": "^2.1.0"
"safer-buffer": "^2.1.0"
}
}
},
},
+ "node_modules/asn1js": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.1.0.tgz",
+ "integrity": "sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "pvtsutils": "^1.3.6",
+ "pvutils": "^1.2.0",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/bcryptjs": {
"node_modules/bcryptjs": {
"version": "2.4.3",
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
@@ -2624,6 +2912,24 @@
@@ -2624,6 +2912,24 @@
"url": "https://opencollective.com/express"
"url": "https://opencollective.com/express"
}
}
},
},
+ "node_modules/pvtsutils": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
+ "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/pvutils": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz",
+ "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
"node_modules/qrcode": {
"node_modules/qrcode": {
"version": "1.5.4",
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
@@ -2723,6 +3029,12 @@
@@ -2723,6 +3029,12 @@
"node": ">=4"
"node": ">=4"
}
}
},
},
+ "node_modules/reflect-metadata": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
+ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
+ "license": "Apache-2.0"
+ },
"node_modules/require-directory": {
"node_modules/require-directory": {
"version": "2.1.1",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -3084,6 +3396,24 @@
@@ -3084,6 +3396,24 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
"license": "0BSD"
},
},
+ "node_modules/tsyringe": {
+ "version": "4.10.0",
+ "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
+ "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^1.9.3"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/tsyringe/node_modules/tslib": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
+ "license": "0BSD"
+ },
"node_modules/type-is": {
"node_modules/type-is": {
"version": "1.6.18",
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -13,6 +13,8 @@
@@ -13,6 +13,8 @@
},
},
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
+ "@simplewebauthn/browser": "13.2.2",
+ "@simplewebauthn/server": "14.1.0",
"bcryptjs": "^2.4.3",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cors": "^2.8.5",
@@ -1112,6 +1112,7 @@ async function continueAnonymous() {
@@ -1112,6 +1112,7 @@ async function continueAnonymous() {
// Pending sign-in lives only in the server's short-lived HttpOnly cookie.
// Pending sign-in lives only in the server's short-lived HttpOnly cookie.
function showMfaChallenge() {
function showMfaChallenge() {
+ networkPasskeys.library().then(lib => lib.WebAuthnAbortService.cancelCeremony()).catch(() => {});
const previous = document.getElementById('mfa-login');
const previous = document.getElementById('mfa-login');
if (previous) previous.remove();
if (previous) previous.remove();
const form = document.createElement('form');
const form = document.createElement('form');
@@ -1137,3 +1138,26 @@ function showMfaChallenge() {
@@ -1137,3 +1138,26 @@ function showMfaChallenge() {
finally { button.disabled = false; }
finally { button.disabled = false; }
});
});
}
}
+
+// Native conditional UI and the explicit button complete the existing SSO continuation.
+async function signInWithPasskey(conditional = false) {
+ const call = async (path, body) => {
+ const res = await fetch(API_BASE + '/api/auth/passkeys/' + path, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || 'Passkey sign-in failed');
+ return data;
+ };
+ try {
+ const data = await networkPasskeys.authenticate(call, 'login', conditional);
+ localStorage.setItem('ov_token', data.token); setAuthCookie(data.token); saveAccount(data.user, data.token);
+ await guardScheduledDeletion(data.token, () => finishSignIn(data));
+ } catch (err) {
+ if (!conditional) document.getElementById('login-msg').textContent = err.message;
+ }
+}
+if (window.PublicKeyCredential) {
+ const button = document.createElement('button'); button.type = 'button'; button.className = 'ovk-btn ovk-btn--quiet'; button.textContent = 'Sign in with a passkey';
+ button.onclick = () => signInWithPasskey();
+ document.getElementById('login-form').after(button);
+ PublicKeyCredential.isConditionalMediationAvailable?.().then(available => { if (available) signInWithPasskey(true); }).catch(() => {});
+}
@@ -27,8 +27,8 @@ async function loadMfaSettings() {
@@ -27,8 +27,8 @@ async function loadMfaSettings() {
await loadMfaSettings();
await loadMfaSettings();
});
});
} else {
} else {
- form('<label>Current password <input name="password" type="password" autocomplete="current-password" required></label>', async data => {
- const setup = await call('enroll', { password: data.get('password') });
+ form('<label>Current password <input name="password" type="password" autocomplete="current-password" required></label><label>Recovery code (if you already have a passkey) <input name="code" autocomplete="one-time-code"></label>', async data => {
+ const setup = await call('enroll', { password: data.get('password'), code: data.get('code') });
form('<p>Scan this QR in your authenticator, or enter the key.</p><img alt="Authenticator setup QR"><p><code></code></p><label>Authenticator code <input name="code" inputmode="numeric" autocomplete="one-time-code" required></label>', async data => {
form('<p>Scan this QR in your authenticator, or enter the key.</p><img alt="Authenticator setup QR"><p><code></code></p><label>Authenticator code <input name="code" inputmode="numeric" autocomplete="one-time-code" required></label>', async data => {
const result = await call('confirm', { code: data.get('code') });
const result = await call('confirm', { code: data.get('code') });
controls.textContent = '';
controls.textContent = '';
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+async function loadPasskeySettings() {
+ const root = document.getElementById('passkey-settings');
+ if (!root || isAnonSession() || !window.PublicKeyCredential) return;
+ try {
+ const state = await apiFetch('/api/auth/passkeys');
+ if (!state.available && !state.keys.length) return;
+ root.hidden = false;
+ root.innerHTML = '<h3>Passkeys</h3><p role="status"></p><form><label>Current password <input name="password" type="password" autocomplete="current-password" required></label><label>Authenticator or recovery code <input name="code" autocomplete="one-time-code"></label><button type="button" data-verify class="ovk-btn ovk-btn--quiet">Verify with a passkey</button><label>Passkey name <input name="name" maxlength="80"></label><button type="submit" class="ovk-btn ovk-btn--primary">Add passkey</button></form><div data-keys></div>';
+ const status = root.querySelector('[role="status"]');
+ const form = root.querySelector('form');
+ let proof;
+ const call = (path, body) => apiFetch('/api/auth/passkeys/' + path, { method: 'POST', body: JSON.stringify(body) });
+ const credentials = () => ({ password: form.elements.password.value, code: form.elements.code.value, proof });
+ const clear = () => { proof = undefined; form.elements.password.value = ''; form.elements.code.value = ''; };
+ root.querySelector('[data-verify]').onclick = async () => {
+ try { proof = (await networkPasskeys.authenticate(call, 'reauth')).proof; status.textContent = 'Passkey verified for one change.'; }
+ catch (err) { status.textContent = err.message; }
+ };
+ form.querySelector('[type="submit"]').hidden = !state.available;
+ form.onsubmit = async e => {
+ e.preventDefault(); const button = form.querySelector('[type="submit"]'); button.disabled = true;
+ try {
+ const result = await networkPasskeys.register(call, credentials(), form.elements.name.value);
+ await loadPasskeySettings();
+ if (result.recovery_codes.length) {
+ const codes = document.createElement('pre'); codes.textContent = result.recovery_codes.join('\n');
+ const note = document.createElement('p'); note.textContent = 'Save these recovery codes now. Each works once; they will not be shown again.';
+ root.append(note, codes);
+ }
+ } catch (err) { status.textContent = err.message; }
+ finally { clear(); button.disabled = false; }
+ };
+ for (const key of state.keys) {
+ const row = document.createElement('p');
+ row.textContent = key.name + ' — ' + (key.backed_up ? 'Synced' : 'Device') + ' — Last used: ' + (key.last_used_at ? new Date(Number(key.last_used_at)).toLocaleString() : 'Never') + ' ';
+ const remove = document.createElement('button'); remove.className = 'ovk-btn ovk-btn--quiet'; remove.textContent = 'Remove';
+ remove.onclick = async () => {
+ remove.disabled = true;
+ try { await call('remove', { ...credentials(), id: key.id }); await loadPasskeySettings(); }
+ catch (err) { status.textContent = err.message; }
+ finally { clear(); remove.disabled = false; }
+ };
+ row.append(remove); root.querySelector('[data-keys]').append(row);
+ }
+ } catch { /* An unavailable pilot never interrupts the account hub. */ }
+}
@@ -139,6 +139,7 @@ async function loadUser() {
@@ -139,6 +139,7 @@ async function loadUser() {
loadLinked();
loadLinked();
loadSessions();
loadSessions();
loadMfaSettings();
loadMfaSettings();
+ loadPasskeySettings();
loadApps();
loadApps();
loadBlocks();
loadBlocks();
loadAnonInfo();
loadAnonInfo();
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+/* Browser ceremonies stay on Network; product sites continue through central SSO. */
+window.networkPasskeys = {
+ library: () => import('/vendor/passkeys/index.js'),
+ async authenticate(call, purpose = 'login', conditional = false) {
+ const lib = await this.library();
+ const optionsJSON = await call(purpose + '/options', {});
+ const response = await lib.startAuthentication({ optionsJSON, useBrowserAutofill: conditional });
+ return call(purpose + '/verify', { response });
+ },
+ async register(call, credentials, name) {
+ const lib = await this.library();
+ const setup = await call('register/options', credentials);
+ const response = await lib.startRegistration({ optionsJSON: setup.options });
+ return call('register/verify', { state: setup.state, response, name });
+ },
+};
@@ -76,7 +76,7 @@
@@ -76,7 +76,7 @@
<form id="login-form" onsubmit="handleLogin(event)">
<form id="login-form" onsubmit="handleLogin(event)">
<div class="field">
<div class="field">
<label>Username</label>
<label>Username</label>
- <input type="text" name="username" placeholder="Your username" required autocomplete="username" autofocus>
+ <input type="text" name="username" placeholder="Your username" required autocomplete="username webauthn" autofocus>
</div>
</div>
<div class="field">
<div class="field">
<label>Password</label>
<label>Password</label>
@@ -203,6 +203,7 @@
@@ -203,6 +203,7 @@
<div class="login-toast" id="toast"></div>
<div class="login-toast" id="toast"></div>
+<script src="/js/passkeys.js" defer></script>
<script src="/js/login.js" defer></script>
<script src="/js/login.js" defer></script>
<script src="/js/account-safety.js" defer></script>
<script src="/js/account-safety.js" defer></script>
<script src="/shared/ov-mark.js" async></script>
<script src="/shared/ov-mark.js" async></script>
@@ -509,6 +509,7 @@
@@ -509,6 +509,7 @@
<div class="section" id="sec-security">
<div class="section" id="sec-security">
<div id="mfa-settings" hidden></div>
<div id="mfa-settings" hidden></div>
+ <div id="passkey-settings" hidden></div>
<div class="card qr-entry" id="qr-signin-card" hidden>
<div class="card qr-entry" id="qr-signin-card" hidden>
<h3><span class="icon"><i class="fa-solid fa-mobile-screen-button"></i></span> Sign in on another device</h3>
<h3><span class="icon"><i class="fa-solid fa-mobile-screen-button"></i></span> Sign in on another device</h3>
<p>Show a code on this device, scan it with the device you want to sign in, then confirm the number it shows.</p>
<p>Show a code on this device, scan it with the device you want to sign in, then confirm the number it shows.</p>
@@ -648,6 +649,8 @@
@@ -648,6 +649,8 @@
<script src="/js/my/consent.js" defer></script>
<script src="/js/my/consent.js" defer></script>
<script src="/js/my/labels.js" defer></script>
<script src="/js/my/labels.js" defer></script>
<script src="/js/my/security.js" defer></script>
<script src="/js/my/security.js" defer></script>
+<script src="/js/passkeys.js" defer></script>
+<script src="/js/my/passkeys.js" defer></script>
<script src="/js/my/mfa.js" defer></script>
<script src="/js/my/mfa.js" defer></script>
<script src="/js/my/approvals.js" defer></script>
<script src="/js/my/approvals.js" defer></script>
<script src="/js/my/apps.js" defer></script>
<script src="/js/my/apps.js" defer></script>
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+'use strict';
+// Run on the deployment host with the existing probe credential in process.env.
+// Never prints credentials, assertions, tokens or recovery codes. No database writes.
+const { createRequire } = require('module');
+const requireLive = createRequire('/opt/openvibe.network/package.json');
+const crypto = require('crypto');
+const { isoCBOR } = requireLive('@simplewebauthn/server/helpers');
+const assert = require('assert');
+const hash = b => crypto.createHash('sha256').update(b).digest();
+const b64 = b => Buffer.from(b).toString('base64url');
+(async () => {
+ const password = process.env.PROBE_MERGE_KEEP_PW;
+ if (!password) throw new Error('Probe credential unavailable');
+ let token; const cookies = new Map(); let keyId; let recovery;
+ const call = async (path, body, signed = false) => {
+ const res = await fetch('https://openvibe.network/api/auth/' + path, { method: body === undefined ? 'GET' : 'POST', headers: { 'content-type': 'application/json', ...(signed ? { authorization: `Bearer ${token}` } : {}), cookie: [...cookies].map(([k,v]) => `${k}=${v}`).join('; ') }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
+ for (const value of res.headers.getSetCookie()) { const [pair] = value.split(';'); const i = pair.indexOf('='); if (pair.slice(i+1)) cookies.set(pair.slice(0,i), pair.slice(i+1)); else cookies.delete(pair.slice(0,i)); }
+ return { status: res.status, body: await res.json() };
+ };
+ const login = () => call('login', { username: 'ovprobe_merge_keep', password });
+ const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
+ const jwk = publicKey.export({ format: 'jwk' }); const id = crypto.randomBytes(32); let counter = 0; let handle;
+ const data = flags => { const c = Buffer.alloc(4); c.writeUInt32BE(++counter); return Buffer.concat([hash('openvibe.network'), Buffer.from([flags]), c]); };
+ const client = (type, options) => Buffer.from(JSON.stringify({ type, challenge: options.challenge, origin: 'https://openvibe.network', crossOrigin: false }));
+ const assertion = options => { const authData = data(5); const c = client('webauthn.get', options); return { id: b64(id), rawId: b64(id), type: 'public-key', clientExtensionResults: {}, response: { clientDataJSON: b64(c), authenticatorData: b64(authData), userHandle: handle, signature: b64(crypto.sign('sha256', Buffer.concat([authData, hash(c)]), privateKey)) } }; };
+ try {
+ let r = await login(); assert.equal(r.status, 200); token = r.body.token; cookies.clear();
+ const initial = await call('passkeys', undefined, true); assert.equal(initial.status, 200); assert.equal(initial.body.keys.length, 0, 'Probe already has a passkey; leave it alone');
+ const mfa = await call('mfa', undefined, true); assert.equal(mfa.body.enabled, false, 'Probe has an authenticator; leave it alone');
+ r = await call('passkeys/register/options', { password }, true); assert.equal(r.status, 200); const setup = r.body; handle = setup.options.user.id;
+ const len = Buffer.alloc(2); len.writeUInt16BE(id.length);
+ const cose = isoCBOR.encode(new Map([[1,2],[3,-7],[-1,1],[-2,Buffer.from(jwk.x,'base64url')],[-3,Buffer.from(jwk.y,'base64url')]]));
+ const authData = Buffer.concat([data(0x45), Buffer.alloc(16), len, id, cose]);
+ const response = { id: b64(id), rawId: b64(id), type: 'public-key', clientExtensionResults: {}, response: { clientDataJSON: b64(client('webauthn.create', setup.options)), attestationObject: b64(isoCBOR.encode(new Map([['fmt','none'],['attStmt',new Map()],['authData',authData]]))), transports: ['internal'] } };
+ r = await call('passkeys/register/verify', { state: setup.state, response, name: 'Temporary production probe' }, true); assert.equal(r.status, 200); keyId = b64(id); recovery = r.body.recovery_codes;
+ console.log('production passkey enrollment: 200');
+ cookies.clear(); r = await call('passkeys/login/options', {}); const body = { response: assertion(r.body) }; const ceremonyCookie = cookies.get('__Host-ov_passkey');
+ r = await call('passkeys/login/verify', body); assert.equal(r.status, 200); console.log('production passkey sign-in: 200');
+ cookies.clear(); cookies.set('__Host-ov_passkey', ceremonyCookie); r = await call('passkeys/login/verify', body); assert.equal(r.status, 401); console.log('production assertion replay: 401');
+ cookies.clear(); assert.equal((await login()).status, 202); r = await call('passkeys/login/options', {}); r = await call('passkeys/login/verify', { response: assertion(r.body) }); assert.equal(r.status, 200); console.log('production password plus passkey: 200');
+ cookies.clear(); assert.equal((await login()).status, 202); assert.equal((await call('mfa/verify', { code: recovery[0] })).status, 200);
+ cookies.clear(); assert.equal((await login()).status, 202); assert.equal((await call('mfa/verify', { code: recovery[0] })).status, 401); console.log('production recovery: 200 then 401 on reuse');
+ } finally {
+ if (keyId) { cookies.clear(); const r = await call('passkeys/remove', { password, code: recovery[1], id: keyId }, true); assert.equal(r.status, 200); console.log('production probe passkey removed: 200'); }
+ }
+ cookies.clear(); assert.equal((await login()).status, 200); console.log('production probe password sign-in restored: 200');
+})().catch(() => { console.error('Production passkey probe failed; details redacted'); process.exitCode = 1; });
@@ -57,7 +57,7 @@ async function remembered(db, req, user, f, now) {
@@ -57,7 +57,7 @@ async function remembered(db, req, user, f, now) {
// Returns true only when the password route must stop before issuing any session.
// Returns true only when the password route must stop before issuing any session.
async function challengeLogin(db, req, res, user) {
async function challengeLogin(db, req, res, user) {
const f = await factor(db, user.id);
const f = await factor(db, user.id);
- if (!f?.secret_ciphertext) return false;
+ if (!f || (!f.secret_ciphertext && !(await require('./passkeys').list(db, user.id)).length)) return false;
keys(); // Fail closed even when enrollment is disabled or encryption configuration is missing.
keys(); // Fail closed even when enrollment is disabled or encryption configuration is missing.
const now = Date.now();
const now = Date.now();
if (await remembered(db, req, user, f, now)) return false;
if (await remembered(db, req, user, f, now)) return false;
@@ -78,9 +78,9 @@ async function challengeLogin(db, req, res, user) {
@@ -78,9 +78,9 @@ async function challengeLogin(db, req, res, user) {
async function consume(db, f, code, now, enrollment = false) {
async function consume(db, f, code, now, enrollment = false) {
if (Number(f.locked_until) > now) return false;
if (Number(f.locked_until) > now) return false;
const ciphertext = enrollment ? f.pending_secret_ciphertext : f.secret_ciphertext;
const ciphertext = enrollment ? f.pending_secret_ciphertext : f.secret_ciphertext;
- if (!ciphertext || (enrollment && Number(f.pending_until) <= now)) return false;
+ if (enrollment && (!ciphertext || Number(f.pending_until) <= now)) return false;
let ok = false;
let ok = false;
- const step = validStep(open(ciphertext, f.user_id), code, now);
+ const step = ciphertext ? validStep(open(ciphertext, f.user_id), code, now) : null;
if (step !== null && step > Number(f.last_step)) {
if (step !== null && step > Number(f.last_step)) {
await db.prepare('UPDATE user_mfa SET last_step = ? WHERE user_id = ?').run(step, f.user_id);
await db.prepare('UPDATE user_mfa SET last_step = ? WHERE user_id = ?').run(step, f.user_id);
ok = true;
ok = true;
@@ -117,9 +117,10 @@ function router({ requireAuth, signToken, sanitizeUser }) {
@@ -117,9 +117,10 @@ function router({ requireAuth, signToken, sanitizeUser }) {
const user = await db.tx(async () => {
const user = await db.tx(async () => {
const c = await db.prepare('SELECT * FROM mfa_challenges WHERE token_hash = ?').get(hash(pending));
const c = await db.prepare('SELECT * FROM mfa_challenges WHERE token_hash = ?').get(hash(pending));
if (!c || Number(c.expires_at) <= now) return null;
if (!c || Number(c.expires_at) <= now) return null;
+ await db.prepare('SELECT id FROM users WHERE id = ? FOR UPDATE').get(c.user_id);
const f = await factor(db, c.user_id, true);
const f = await factor(db, c.user_id, true);
const u = await db.prepare('SELECT * FROM users WHERE id = ?').get(c.user_id);
const u = await db.prepare('SELECT * FROM users WHERE id = ?').get(c.user_id);
- if (!f?.secret_ciphertext || f.generation !== c.generation || !u || u.is_banned || u.deleted_at || u.merged_into || hash(u.password_hash) !== c.credential_hash) return null;
+ if (!f || (!f.secret_ciphertext && !(await require('./passkeys').list(db, c.user_id)).length) || f.generation !== c.generation || !u || u.is_banned || u.deleted_at || u.merged_into || hash(u.password_hash) !== c.credential_hash) return null;
if (!await db.prepare('SELECT token_hash FROM mfa_challenges WHERE token_hash = ?').get(hash(pending))) return null;
if (!await db.prepare('SELECT token_hash FROM mfa_challenges WHERE token_hash = ?').get(hash(pending))) return null;
if (!await consume(db, f, cleanCode(req.body.code), now)) return null;
if (!await consume(db, f, cleanCode(req.body.code), now)) return null;
await db.prepare('DELETE FROM mfa_challenges WHERE token_hash = ?').run(hash(pending));
await db.prepare('DELETE FROM mfa_challenges WHERE token_hash = ?').run(hash(pending));
@@ -149,6 +150,8 @@ function router({ requireAuth, signToken, sanitizeUser }) {
@@ -149,6 +150,8 @@ function router({ requireAuth, signToken, sanitizeUser }) {
if (typeof req.body.password !== 'string' || !bcrypt.compareSync(req.body.password, req.user.password_hash)) return res.status(401).json({ error: 'Current password required' });
if (typeof req.body.password !== 'string' || !bcrypt.compareSync(req.body.password, req.user.password_hash)) return res.status(401).json({ error: 'Current password required' });
const secret = new OTPAuth.Secret({ size: 20 }).base32;
const secret = new OTPAuth.Secret({ size: 20 }).base32;
const result = await db.tx(async () => {
const result = await db.tx(async () => {
+ req.user = await db.prepare('SELECT * FROM users WHERE id = ? FOR UPDATE').get(req.user.id);
+ if ((await require('./passkeys').list(db, req.user.id)).length && !await require('./passkeys').authorize(db, req)) return false;
await db.prepare('INSERT INTO user_mfa (user_id, generation) VALUES (?, ?) ON CONFLICT (user_id) DO NOTHING').run(req.user.id, random());
await db.prepare('INSERT INTO user_mfa (user_id, generation) VALUES (?, ?) ON CONFLICT (user_id) DO NOTHING').run(req.user.id, random());
const f = await factor(db, req.user.id, true);
const f = await factor(db, req.user.id, true);
if (f.secret_ciphertext || Number(f.locked_until) > Date.now()) return false;
if (f.secret_ciphertext || Number(f.locked_until) > Date.now()) return false;
@@ -161,12 +164,14 @@ function router({ requireAuth, signToken, sanitizeUser }) {
@@ -161,12 +164,14 @@ function router({ requireAuth, signToken, sanitizeUser }) {
r.post('/confirm', route(async (req, res, db) => {
r.post('/confirm', route(async (req, res, db) => {
if (!await allowed(db, req.user)) return res.status(404).json({ error: 'Not available' });
if (!await allowed(db, req.user)) return res.status(404).json({ error: 'Not available' });
const codes = await db.tx(async () => {
const codes = await db.tx(async () => {
+ await db.prepare('SELECT id FROM users WHERE id = ? FOR UPDATE').get(req.user.id);
const f = await factor(db, req.user.id, true);
const f = await factor(db, req.user.id, true);
if (!f || f.secret_ciphertext || !await consume(db, f, cleanCode(req.body.code), Date.now(), true)) return null;
if (!f || f.secret_ciphertext || !await consume(db, f, cleanCode(req.body.code), Date.now(), true)) return null;
const codes = Array.from({ length: 10 }, () => crypto.randomBytes(10).toString('hex').toUpperCase());
const codes = Array.from({ length: 10 }, () => crypto.randomBytes(10).toString('hex').toUpperCase());
await db.prepare('DELETE FROM mfa_recovery_codes WHERE user_id = ?').run(req.user.id);
await db.prepare('DELETE FROM mfa_recovery_codes WHERE user_id = ?').run(req.user.id);
for (const code of codes) await db.prepare('INSERT INTO mfa_recovery_codes (user_id, code_hash) VALUES (?, ?)').run(req.user.id, await bcrypt.hash(code, 10));
for (const code of codes) await db.prepare('INSERT INTO mfa_recovery_codes (user_id, code_hash) VALUES (?, ?)').run(req.user.id, await bcrypt.hash(code, 10));
await db.prepare('UPDATE user_mfa SET secret_ciphertext = pending_secret_ciphertext, pending_secret_ciphertext = NULL, pending_until = 0, enabled_at = ? WHERE user_id = ?').run(Date.now(), req.user.id);
await db.prepare('UPDATE user_mfa SET secret_ciphertext = pending_secret_ciphertext, pending_secret_ciphertext = NULL, pending_until = 0, enabled_at = ? WHERE user_id = ?').run(Date.now(), req.user.id);
+ for (const table of ['passkey_challenges', 'mfa_challenges', 'mfa_devices']) await db.prepare(`DELETE FROM ${table} WHERE user_id = ?`).run(req.user.id);
return codes;
return codes;
});
});
if (!codes) return res.status(401).json({ error: 'Code or enrollment invalid' });
if (!codes) return res.status(401).json({ error: 'Code or enrollment invalid' });
@@ -174,9 +179,15 @@ function router({ requireAuth, signToken, sanitizeUser }) {
@@ -174,9 +179,15 @@ function router({ requireAuth, signToken, sanitizeUser }) {
}));
}));
r.post('/disable', route(async (req, res, db) => {
r.post('/disable', route(async (req, res, db) => {
const ok = await db.tx(async () => {
const ok = await db.tx(async () => {
+ await db.prepare('SELECT id FROM users WHERE id = ? FOR UPDATE').get(req.user.id);
const f = await factor(db, req.user.id, true);
const f = await factor(db, req.user.id, true);
if (!f || !await consume(db, f, cleanCode(req.body.code), Date.now())) return false;
if (!f || !await consume(db, f, cleanCode(req.body.code), Date.now())) return false;
- for (const table of ['mfa_recovery_codes', 'mfa_challenges', 'mfa_devices', 'user_mfa']) await db.prepare(`DELETE FROM ${table} WHERE user_id = ?`).run(req.user.id);
+ if ((await require('./passkeys').list(db, req.user.id)).length) {
+ await db.prepare('UPDATE user_mfa SET secret_ciphertext = NULL, pending_secret_ciphertext = NULL, generation = ? WHERE user_id = ?').run(random(), req.user.id);
+ for (const table of ['mfa_challenges', 'mfa_devices', 'passkey_challenges']) await db.prepare(`DELETE FROM ${table} WHERE user_id = ?`).run(req.user.id);
+ } else {
+ for (const table of ['mfa_recovery_codes', 'mfa_challenges', 'mfa_devices', 'user_mfa']) await db.prepare(`DELETE FROM ${table} WHERE user_id = ?`).run(req.user.id);
+ }
return true;
return true;
});
});
res.status(ok ? 200 : 401).json(ok ? { ok: true } : { error: 'Code invalid' });
res.status(ok ? 200 : 401).json(ok ? { ok: true } : { error: 'Code invalid' });
@@ -188,4 +199,4 @@ function router({ requireAuth, signToken, sanitizeUser }) {
@@ -188,4 +199,4 @@ function router({ requireAuth, signToken, sanitizeUser }) {
}));
}));
return r;
return r;
}
}
-module.exports = { router, challengeLogin, validStep, totp, hash };
+module.exports = { router, challengeLogin, validStep, totp, hash, allowed, factor, consume, cleanCode };
new file mode 100644
new file mode 100644
@@ -0,0 +1,180 @@
@@ -0,0 +1,180 @@
+'use strict';
+const crypto = require('crypto');
+const express = require('express');
+const bcrypt = require('bcryptjs');
+const rateLimit = require('express-rate-limit');
+const webauthn = require('@simplewebauthn/server');
+const mfa = require('./mfa');
+const RP = 'openvibe.network';
+const ORIGIN = 'https://openvibe.network';
+const COOKIE = '__Host-ov_passkey';
+const cookie = { httpOnly: true, secure: true, sameSite: 'strict', path: '/' };
+const random = () => crypto.randomBytes(32).toString('base64url');
+const hash = mfa.hash;
+const liveUser = u => u && !u.is_banned && !u.deleted_at && !u.merged_into;
+const binding = req => req.tokenClaims?.jti || '';
+async function list(db, id) { return db.prepare('SELECT * FROM user_passkeys WHERE user_id = ? ORDER BY created_at').all(id); }
+async function save(db, purpose, challenge, user, bind) {
+ const token = random();
+ await db.prepare('DELETE FROM passkey_challenges WHERE expires_at <= ?').run(Date.now());
+ await db.prepare('INSERT INTO passkey_challenges (token_hash, user_id, purpose, challenge, binding, credential_hash, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
+ .run(hash(token), user?.id || null, purpose, challenge, bind, user ? hash(user.password_hash) : '', Date.now() + 300000);
+ return token;
+}
+async function take(db, token, purpose, bind) {
+ if (typeof token !== 'string' || token.length > 100) return null;
+ return db.prepare('DELETE FROM passkey_challenges WHERE token_hash = ? AND purpose = ? AND binding = ? AND expires_at > ? RETURNING *').get(hash(token), purpose, bind, Date.now());
+}
+// Inside the locked account transaction. Each management proof authorizes one operation.
+async function authorize(db, req) {
+ if (!binding(req) || typeof req.body.password !== 'string' || !await bcrypt.compare(req.body.password, req.user.password_hash)) return false;
+ const f = await mfa.factor(db, req.user.id, true);
+ if (!f?.secret_ciphertext && !(await list(db, req.user.id)).length) return true;
+ const proof = await take(db, req.body.proof, 'proof', binding(req));
+ if (proof && proof.user_id === req.user.id && proof.credential_hash === hash(req.user.password_hash)) return true;
+ return !!f && await mfa.consume(db, f, mfa.cleanCode(req.body.code), Date.now());
+}
+function router({ requireAuth, signToken, sanitizeUser }) {
+ const r = express.Router();
+ r.use(rateLimit({ windowMs: 60000, max: 30, standardHeaders: true, legacyHeaders: false }));
+ 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' });
+ if (req.headers.origin && req.headers.origin !== ORIGIN) return res.status(403).json({ error: 'Network origin required' });
+ if (req.headers['sec-fetch-site'] === 'cross-site') return res.status(403).json({ error: 'Same-site request required' });
+ next();
+ });
+ const route = fn => async (req, res) => { try { await fn(req, res, req.app.locals.db); } catch { res.status(503).json({ error: 'Passkey service unavailable' }); } };
+ const fail = res => res.status(401).json({ error: 'Passkey or authentication state invalid' });
+ async function options(req, res, db, purpose) {
+ const options = await webauthn.generateAuthenticationOptions({ rpID: RP, userVerification: 'required', ...(purpose === 'reauth' ? { allowCredentials: (await list(db, req.user.id)).map(k => ({ id: k.id })) } : {}) });
+ const pending = req.cookies?.['__Host-ov_mfa_pending'];
+ // A password challenge is bound at creation; it cannot later switch accounts.
+ const bind = purpose === 'reauth' ? binding(req) : (pending ? hash(pending) : '');
+ const token = await save(db, purpose, options.challenge, purpose === 'reauth' ? req.user : null, bind);
+ res.cookie(COOKIE, token, { ...cookie, maxAge: 300000 });
+ res.json(options);
+ }
+ async function verify(req, res, db, purpose) {
+ const pending = req.cookies?.['__Host-ov_mfa_pending'];
+ const bind = purpose === 'reauth' ? binding(req) : (pending ? hash(pending) : '');
+ const result = await db.tx(async () => {
+ const token = req.cookies?.[COOKIE];
+ if (typeof token !== 'string' || token.length > 100) return null;
+ const c = await db.prepare('SELECT * FROM passkey_challenges WHERE token_hash = ? AND purpose = ? AND binding = ? AND expires_at > ?').get(hash(token), purpose, bind, Date.now());
+ if (!c || !req.body.response || typeof req.body.response.id !== 'string') return null;
+ const key = await db.prepare('SELECT * FROM user_passkeys WHERE id = ?').get(req.body.response.id);
+ if (!key || (c.user_id && c.user_id !== key.user_id)) return null;
+ const u = await db.prepare('SELECT * FROM users WHERE id = ? FOR UPDATE').get(key.user_id);
+ if (!liveUser(u) || (c.user_id && c.credential_hash !== hash(u.password_hash))) return null;
+ if (!await take(db, token, purpose, bind)) return null;
+ const f = await mfa.factor(db, u.id, true);
+ if (!f || Number(f.locked_until) > Date.now()) return null;
+ if (bind && purpose === 'login') {
+ const p = await db.prepare('SELECT * FROM mfa_challenges WHERE token_hash = ?').get(bind);
+ if (!p || p.user_id !== u.id || p.generation !== f.generation || p.credential_hash !== hash(u.password_hash) || Number(p.expires_at) <= 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: req.body.response, expectedChallenge: c.challenge,
+ expectedOrigin: ORIGIN, 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 }; }
+ if (!checked.verified || (req.body.response.response.userHandle ? req.body.response.response.userHandle !== f.passkey_handle : purpose === 'login')) {
+ const failures = Number(f.locked_until) > 0 ? 1 : f.failures + 1;
+ await db.prepare('UPDATE user_mfa SET failures = ?, locked_until = ? WHERE user_id = ?').run(failures, failures >= 5 ? Date.now() + 300000 : 0, u.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(u.id);
+ if (purpose === 'reauth') return { proof: await save(db, 'proof', '', u, binding(req)) };
+ if (bind) await db.prepare('DELETE FROM mfa_challenges WHERE token_hash = ?').run(bind);
+ return { user: u };
+ });
+ res.clearCookie(COOKIE, cookie);
+ if (!result) return fail(res);
+ if (result.proof) return res.json({ proof: result.proof });
+ res.clearCookie('__Host-ov_mfa_pending', cookie);
+ const token = signToken(result.user, req.app.locals.privateKey, req.app.locals.config);
+ require('./session').setSessionCookies(res, token);
+ res.json({ token, user: sanitizeUser(result.user) });
+ }
+ r.post('/login/options', route((req, res, db) => options(req, res, db, 'login')));
+ 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 })) })));
+ r.post('/reauth/options', route((req, res, db) => options(req, res, db, 'reauth')));
+ r.post('/reauth/verify', route((req, res, db) => verify(req, res, db, 'reauth')));
+ r.post('/register/options', route(async (req, res, db) => {
+ if (!await mfa.allowed(db, req.user)) return res.status(404).json({ error: 'Not available' });
+ const out = await db.tx(async () => {
+ req.user = await db.prepare('SELECT * FROM users WHERE id = ? FOR UPDATE').get(req.user.id);
+ if (!await authorize(db, req)) return null;
+ const keys = await list(db, req.user.id);
+ if (keys.length >= 10) return null;
+ await db.prepare('INSERT INTO user_mfa (user_id, generation, passkey_handle) VALUES (?, ?, ?) ON CONFLICT (user_id) DO NOTHING').run(req.user.id, random(), random());
+ await db.prepare('UPDATE user_mfa SET passkey_handle = ? WHERE user_id = ? AND passkey_handle IS NULL').run(random(), req.user.id);
+ const f = await mfa.factor(db, req.user.id);
+ const options = await webauthn.generateRegistrationOptions({ rpName: 'OpenVibe.Network', rpID: RP, userName: req.user.username,
+ userID: new Uint8Array(Buffer.from(f.passkey_handle, 'base64url')), attestationType: 'none',
+ excludeCredentials: keys.map(k => ({ id: k.id })), authenticatorSelection: { residentKey: 'required', userVerification: 'required' } });
+ const state = await save(db, 'register', options.challenge, req.user, binding(req));
+ return { options, state };
+ });
+ if (!out) return fail(res);
+ res.json(out);
+ }));
+ r.post('/register/verify', route(async (req, res, db) => {
+ if (!await mfa.allowed(db, req.user)) return res.status(404).json({ error: 'Not available' });
+ const out = await db.tx(async () => {
+ const u = await db.prepare('SELECT * FROM users WHERE id = ? FOR UPDATE').get(req.user.id);
+ const c = await take(db, req.body.state, 'register', binding(req));
+ if (!c || c.user_id !== u.id || c.credential_hash !== hash(u.password_hash) || !liveUser(u) || (await list(db, u.id)).length >= 10) return null;
+ let checked;
+ try {
+ const client = JSON.parse(Buffer.from(req.body.response.response.clientDataJSON, 'base64url').toString());
+ if (client.crossOrigin || client.topOrigin) return null;
+ checked = await webauthn.verifyRegistrationResponse({ response: req.body.response, expectedChallenge: c.challenge, expectedOrigin: ORIGIN, expectedRPID: RP, requireUserVerification: true }); } catch { return null; }
+ if (!checked.verified) return null;
+ const info = checked.registrationInfo;
+ if (await db.prepare('SELECT id FROM user_passkeys WHERE id = ?').get(info.credential.id)) return null;
+ const name = typeof req.body.name === 'string' ? req.body.name.trim().slice(0, 80) : '';
+ await db.prepare('INSERT INTO user_passkeys (id, user_id, public_key, counter, name, device_type, backed_up, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
+ .run(info.credential.id, u.id, Buffer.from(info.credential.publicKey).toString('base64url'), info.credential.counter, name || 'Passkey', info.credentialDeviceType, info.credentialBackedUp, Date.now());
+ await db.prepare('INSERT INTO user_mfa (user_id, generation) VALUES (?, ?) ON CONFLICT (user_id) DO NOTHING').run(u.id, random());
+ let codes = [];
+ if (!(await db.prepare('SELECT code_hash FROM mfa_recovery_codes WHERE user_id = ?').get(u.id))) {
+ codes = Array.from({ length: 10 }, () => crypto.randomBytes(10).toString('hex').toUpperCase());
+ for (const code of codes) await db.prepare('INSERT INTO mfa_recovery_codes (user_id, code_hash) VALUES (?, ?)').run(u.id, await bcrypt.hash(code, 10));
+ }
+ await invalidate(db, u.id);
+ return { ok: true, recovery_codes: codes };
+ });
+ if (!out) return fail(res);
+ res.json(out);
+ }));
+ r.post('/remove', route(async (req, res, db) => {
+ const ok = await db.tx(async () => {
+ req.user = await db.prepare('SELECT * FROM users WHERE id = ? FOR UPDATE').get(req.user.id);
+ if (!await authorize(db, req)) return false;
+ const removed = await db.prepare('DELETE FROM user_passkeys WHERE id = ? AND user_id = ?').run(typeof req.body.id === 'string' ? req.body.id : '', req.user.id);
+ if (!removed.changes) return false;
+ await invalidate(db, req.user.id);
+ if (!(await list(db, req.user.id)).length && !(await mfa.factor(db, req.user.id))?.secret_ciphertext) {
+ await db.prepare('DELETE FROM mfa_recovery_codes WHERE user_id = ?').run(req.user.id);
+ }
+ return true;
+ });
+ if (!ok) return fail(res);
+ res.json({ ok: true });
+ }));
+ return r;
+}
+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 };
@@ -52,6 +52,7 @@ async function revokeTokens(db, userId, { reason, actor, ctx, strict = false } =
@@ -52,6 +52,7 @@ async function revokeTokens(db, userId, { reason, actor, ctx, strict = false } =
if (!user) throw new Error(`revocation: no user ${userId}`);
if (!user) throw new Error(`revocation: no user ${userId}`);
await db.prepare(strict ? "UPDATE users SET token_valid_after = datetime(ov_now(), '+1 second') WHERE id = ?" : 'UPDATE users SET token_valid_after = ov_now() WHERE id = ?').run(user.id);
await db.prepare(strict ? "UPDATE users SET token_valid_after = datetime(ov_now(), '+1 second') WHERE id = ?" : 'UPDATE users SET token_valid_after = ov_now() WHERE id = ?').run(user.id);
await db.prepare('UPDATE user_sessions SET is_active = 0 WHERE user_id = ?').run(user.id);
await db.prepare('UPDATE user_sessions SET is_active = 0 WHERE user_id = ?').run(user.id);
+ await db.prepare('DELETE FROM passkey_challenges WHERE user_id = ?').run(user.id);
await db.prepare('DELETE FROM mfa_devices WHERE user_id = ?').run(user.id);
await db.prepare('DELETE FROM mfa_devices WHERE user_id = ?').run(user.id);
await db.prepare('DELETE FROM mfa_challenges WHERE user_id = ?').run(user.id);
await db.prepare('DELETE FROM mfa_challenges WHERE user_id = ?').run(user.id);
const validAfter = iso((await db.prepare('SELECT token_valid_after AS t FROM users WHERE id = ?').get(user.id)).t);
const validAfter = iso((await db.prepare('SELECT token_valid_after AS t FROM users WHERE id = ?').get(user.id)).t);
@@ -109,6 +109,7 @@ const { makeRequireAuth, GRACE_MS: SESSION_GRACE_MS } = require('./session');
@@ -109,6 +109,7 @@ const { makeRequireAuth, GRACE_MS: SESSION_GRACE_MS } = require('./session');
// Sliding session guard: expired-but-renewable tokens are accepted and renewed (cookie + X-OV-Token).
// Sliding session guard: expired-but-renewable tokens are accepted and renewed (cookie + X-OV-Token).
const requireAuth = makeRequireAuth(async (req) => ({ db: await getDb(req), publicKey: req.app.locals.publicKey, config: getConfig(req) }), signToken);
const requireAuth = makeRequireAuth(async (req) => ({ db: await getDb(req), publicKey: req.app.locals.publicKey, config: getConfig(req) }), signToken);
+router.use('/passkeys', require('./passkeys').router({ requireAuth, signToken, sanitizeUser }));
router.use('/mfa', require('./mfa').router({ requireAuth, signToken, sanitizeUser }));
router.use('/mfa', require('./mfa').router({ requireAuth, signToken, sanitizeUser }));
// ── Register ─────────────────────────────────────────────────
// ── Register ─────────────────────────────────────────────────
@@ -175,8 +175,8 @@ async function networkPart(db, userId) {
@@ -175,8 +175,8 @@ async function networkPart(db, userId) {
const byUser = async (name, table, opts) => { const r = await rowsOf(db, table, 'user_id = ?', [u.id], opts); add(name, r.rows, r.truncated); };
const byUser = async (name, table, opts) => { const r = await rowsOf(db, table, 'user_id = ?', [u.id], opts); add(name, r.rows, r.truncated); };
await byUser('sign_in_providers.json', 'linked_accounts');
await byUser('sign_in_providers.json', 'linked_accounts');
await byUser('sessions.json', 'user_sessions');
await byUser('sessions.json', 'user_sessions');
- for (const table of ['user_mfa', 'mfa_recovery_codes', 'mfa_challenges', 'mfa_devices']) {
- await byUser(`${table}.json`, table, { omit: ['generation'] });
+ for (const table of ['user_mfa', 'mfa_recovery_codes', 'mfa_challenges', 'mfa_devices', 'user_passkeys', 'passkey_challenges']) {
+ await byUser(`${table}.json`, table, { omit: ['generation', 'challenge', 'binding', 'public_key', 'passkey_handle'] });
}
}
await byUser('oauth_grants.json', 'oauth_tokens');
await byUser('oauth_grants.json', 'oauth_tokens');
await byUser('notifications.json', 'notifications');
await byUser('notifications.json', 'notifications');
@@ -362,7 +362,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
@@ -362,7 +362,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
const inS = `(${subjects.map(() => '?').join(',')})`;
const inS = `(${subjects.map(() => '?').join(',')})`;
const del = async (table, where, params, key = table) => { if (await tableExists(db, table)) count(key, (await db.prepare(`DELETE FROM ${table} WHERE ${where}`).run(...params)).changes); };
const del = async (table, where, params, key = table) => { if (await tableExists(db, table)) count(key, (await db.prepare(`DELETE FROM ${table} WHERE ${where}`).run(...params)).changes); };
- for (const t of ['mfa_recovery_codes', 'mfa_challenges', 'mfa_devices', 'user_mfa', 'linked_accounts', 'user_sessions', 'oauth_tokens', 'oauth_codes', 'email_verification_tokens', 'password_reset_tokens', 'notifications',
+ for (const t of ['user_passkeys', 'passkey_challenges', 'mfa_recovery_codes', 'mfa_challenges', 'mfa_devices', 'user_mfa', 'linked_accounts', 'user_sessions', 'oauth_tokens', 'oauth_codes', 'email_verification_tokens', 'password_reset_tokens', 'notifications',
'notification_preferences', 'notification_quiet_hours', 'notification_digest_runs', 'user_preferences', 'user_effects', 'push_subscriptions', 'user_history', 'username_history', 'user_profile_changes',
'notification_preferences', 'notification_quiet_hours', 'notification_digest_runs', 'user_preferences', 'user_effects', 'push_subscriptions', 'user_history', 'username_history', 'user_profile_changes',
'ip_log', 'email_delivery_log', 'user_theme_history', 'user_theme_site_overrides']) await del(t, `user_id IN ${inU}`, userIds);
'ip_log', 'email_delivery_log', 'user_theme_history', 'user_theme_site_overrides']) await del(t, `user_id IN ${inU}`, userIds);
await del('experience_events', `user_id IN ${inU}`, userIds);
await del('experience_events', `user_id IN ${inU}`, userIds);
@@ -1172,6 +1172,7 @@ app.get(['/sso/fanout', '/sso/fanout.html'], (req, res) => {
@@ -1172,6 +1172,7 @@ app.get(['/sso/fanout', '/sso/fanout.html'], (req, res) => {
// The public pages carry the look before first paint; the ones the static file server would answer by their own
// The public pages carry the look before first paint; the ones the static file server would answer by their own
// file name (public/*.html) get it here, ahead of express.static below (plan L42).
// file name (public/*.html) get it here, ahead of express.static below (plan L42).
app.use(lookBoot.middleware());
app.use(lookBoot.middleware());
+app.use('/vendor/passkeys', express.static(path.join(path.dirname(require.resolve('@simplewebauthn/browser')), '../esm')));
app.use(express.static(path.join(__dirname, '..', 'public'), { setHeaders: require('./static-headers').publicStaticHeaders }));
app.use(express.static(path.join(__dirname, '..', 'public'), { setHeaders: require('./static-headers').publicStaticHeaders }));
// openvibe-shared: the OpenVibe.Shared release package.json pins, from node_modules.
// openvibe-shared: the OpenVibe.Shared release package.json pins, from node_modules.
@@ -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'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', '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'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', '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,34 @@
@@ -0,0 +1,34 @@
+'use strict';
+// A software ES256 authenticator: exercises the real verifier, never replaces it.
+const crypto = require('crypto');
+const { isoCBOR } = require('@simplewebauthn/server/helpers');
+const hash = b => crypto.createHash('sha256').update(b).digest();
+const b64 = b => Buffer.from(b).toString('base64url');
+function authenticator() {
+ const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
+ const jwk = publicKey.export({ format: 'jwk' });
+ const id = crypto.randomBytes(32); let counter = 0; let handle;
+ const cose = isoCBOR.encode(new Map([[1, 2], [3, -7], [-1, 1], [-2, Buffer.from(jwk.x, 'base64url')], [-3, Buffer.from(jwk.y, 'base64url')]]));
+ const data = (flags, rp) => { const c = Buffer.alloc(4); c.writeUInt32BE(++counter); return Buffer.concat([hash(rp), Buffer.from([flags]), c]); };
+ const client = (type, options, origin) => Buffer.from(JSON.stringify({ type, challenge: options.challenge, origin, crossOrigin: false }));
+ return {
+ id: b64(id),
+ register(options, { origin = 'https://openvibe.network', rp = 'openvibe.network', flags = 0x45 } = {}) {
+ handle = options.user.id;
+ const length = Buffer.alloc(2); length.writeUInt16BE(id.length);
+ const authData = Buffer.concat([data(flags, rp), Buffer.alloc(16), length, id, cose]);
+ return { id: b64(id), rawId: b64(id), type: 'public-key', clientExtensionResults: {}, response: {
+ clientDataJSON: b64(client('webauthn.create', options, origin)),
+ attestationObject: b64(isoCBOR.encode(new Map([['fmt', 'none'], ['attStmt', new Map()], ['authData', authData]]))), transports: ['internal'],
+ } };
+ },
+ assert(options, { origin = 'https://openvibe.network', rp = 'openvibe.network', flags = 5, wrongHandle = false } = {}) {
+ const authData = data(flags, rp); const clientData = client('webauthn.get', options, origin);
+ return { id: b64(id), rawId: b64(id), type: 'public-key', clientExtensionResults: {}, response: {
+ clientDataJSON: b64(clientData), authenticatorData: b64(authData), userHandle: wrongHandle ? b64('another account') : handle,
+ signature: b64(crypto.sign('sha256', Buffer.concat([authData, hash(clientData)]), privateKey)),
+ } };
+ },
+ };
+}
+module.exports = { authenticator };
@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
const files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();
const files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();
// The module map, in load order (public/js/my/…).
// The module map, in load order (public/js/my/…).
-const modules = ['core.js', 'tools.js', 'today.js', 'machines.js', 'themes.js', 'profile.js', 'emails.js', 'consent.js', 'labels.js', 'security.js', 'mfa.js', 'approvals.js', 'apps.js', 'receipts.js', 'boot.js'];
+const modules = ['core.js', 'tools.js', 'today.js', 'machines.js', 'themes.js', 'profile.js', 'emails.js', 'consent.js', 'labels.js', 'security.js', 'passkeys.js', 'mfa.js', 'approvals.js', 'apps.js', 'receipts.js', 'boot.js'];
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];
const loaded = tags.map((m) => m[2]);
const loaded = tags.map((m) => m[2]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,132 @@
@@ -0,0 +1,132 @@
+'use strict';
+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 accountData = require('../server/identity/account-data');
+(async () => {
+ process.env.NETWORK_MFA_KEY = crypto.randomBytes(32).toString('hex');
+ const db = getDb(); const pw = 'test-only-password';
+ await db.prepare('INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?), (?, ?, ?)').run(71, 'passkey_alice', bcrypt.hashSync(pw, 4), 72, 'passkey_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`;
+ await require('../server/identity/subjects').ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = 72').get());
+ const alice = await db.prepare('SELECT * FROM users WHERE id = 71').get(); const bob = await db.prepare('SELECT * FROM users WHERE id = 72').get();
+ const at = auth.signToken(alice, keys.privateKey, config); const bt = auth.signToken(bob, keys.privateKey, config);
+ const call = async (path, body, token, cookies) => {
+ const res = await fetch(base + path, { method: body === undefined ? 'GET' : 'POST', headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}), ...(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 login = () => call('/login', { username: alice.username, password: pw });
+ // Keep each phase below the production per-minute limiter without weakening middleware.
+ const realNow = Date.now; let now = realNow(); Date.now = () => now;
+ const next = () => { now += 61000; };
+ const enroll = async (device, token = at, credentials = {}) => {
+ const r = await call('/passkeys/register/options', { password: pw, ...credentials }, token); assert.equal(r.status, 200);
+ const payload = { state: r.body.state, response: device.register(r.body.options), name: 'Test passkey' };
+ const result = await call('/passkeys/register/verify', payload, token);
+ if (result.status === 200) assert.equal((await call('/passkeys/register/verify', payload, token)).status, 401, 'registration state is single use');
+ return result;
+ };
+ const assertion = async (device, overrides = {}, pending = '') => {
+ const r = await call('/passkeys/login/options', {}, null, pending); assert.equal(r.status, 200);
+ const body = { response: device.assert(r.body, overrides) }; const cookies = [cookieOf(r), pending].filter(Boolean).join('; ');
+ return { r: await call('/passkeys/login/verify', body, null, cookies), body, cookies };
+ };
+ try {
+ assert.equal((await login()).status, 200);
+ assert.equal((await call('/passkeys/register/options', { password: pw }, at)).status, 404);
+ await db.prepare("UPDATE site_settings SET value = 'all' WHERE key = 'mfa_enrollment'").run();
+ assert.equal((await call('/passkeys/register/options', { password: 'wrong' }, at)).status, 401);
+ const device = authenticator();
+ let start = await call('/passkeys/register/options', { password: pw }, at);
+ assert.equal(start.body.options.rp.id, 'openvibe.network'); assert.equal(start.body.options.attestation, 'none'); assert.equal(start.body.options.authenticatorSelection.userVerification, 'required');
+ let payload = { state: start.body.state, response: device.register(start.body.options) };
+ assert.equal((await call('/passkeys/register/verify', payload, bt)).status, 401, 'other account');
+ await db.prepare('UPDATE passkey_challenges SET expires_at = 0').run();
+ assert.equal((await call('/passkeys/register/verify', payload, at)).status, 401, 'expired');
+ for (const invalid of [{ origin: 'https://evil.example' }, { rp: 'evil.example' }, { flags: 0x41 }]) {
+ start = await call('/passkeys/register/options', { password: pw }, at);
+ assert.equal((await call('/passkeys/register/verify', { state: start.body.state, response: device.register(start.body.options, invalid) }, at)).status, 401);
+ }
+ next();
+ const enrolled = await enroll(device); assert.equal(enrolled.status, 200); const codes = enrolled.body.recovery_codes; assert.equal(codes.length, 10);
+ assert.equal((await login()).status, 202, 'passkey protects password fallback');
+ assert.equal((await call('/mfa/enroll', { password: pw }, at)).status, 409, 'adding TOTP also requires existing factor');
+ assert.equal((await call('/passkeys/register/options', { password: pw }, at)).status, 401, 'existing factor required');
+ start = await call('/passkeys/login/options', {});
+ payload = { response: device.assert(start.body) };
+ const race = await Promise.all([call('/passkeys/login/verify', payload, null, cookieOf(start)), call('/passkeys/login/verify', payload, null, cookieOf(start))]);
+ assert.deepEqual(race.map(r => r.status).sort(), [200, 401], 'atomic assertion consumption');
+ let result = await assertion(device); assert.equal(result.r.status, 200, 'real ES256 assertion'); assert.ok(result.r.body.token);
+ assert.equal((await call('/passkeys/login/verify', result.body, null, result.cookies)).status, 401, 'replay');
+ for (const invalid of [{ origin: 'https://evil.example' }, { rp: 'evil.example' }, { flags: 1 }, { wrongHandle: true }]) {
+ assert.equal((await assertion(device, invalid)).r.status, 401);
+ }
+ assert.equal((await assertion(device, { flags: 1 })).r.status, 401, 'fifth failure');
+ assert.equal((await assertion(device)).r.status, 401, 'valid assertion waits during lockout');
+ assert.equal((await login()).status, 429, 'account lockout spans password and passkeys');
+ now += 301000;
+ next();
+ start = await call('/passkeys/login/options', {}); payload = { response: device.assert(start.body) };
+ await db.prepare('UPDATE passkey_challenges SET expires_at = 0').run();
+ assert.equal((await call('/passkeys/login/verify', payload, null, cookieOf(start))).status, 401);
+ // A valid assertion resets the shared failed-factor count.
+ assert.equal((await assertion(device)).r.status, 200);
+ const bobDevice = authenticator(); assert.equal((await enroll(bobDevice, bt)).status, 200);
+ let pending = cookieOf(await login(), '__Host-ov_mfa_pending');
+ assert.equal((await assertion(bobDevice, {}, pending)).r.status, 401, 'pending account cannot switch');
+ assert.equal((await assertion(device, {}, pending)).r.status, 200, 'passkey second factor');
+ assert.equal((await call('/mfa/verify', { code: codes[0] }, null, pending)).status, 401, 'pending consumed');
+ pending = cookieOf(await login(), '__Host-ov_mfa_pending');
+ assert.equal((await call('/mfa/verify', { code: codes[0] }, null, pending)).status, 200, 'passkey-only recovery');
+ pending = cookieOf(await login(), '__Host-ov_mfa_pending');
+ assert.equal((await call('/mfa/verify', { code: codes[0] }, null, pending)).status, 401, 'reused recovery');
+ next();
+ start = await call('/passkeys/reauth/options', {}, at);
+ let reauth = await call('/passkeys/reauth/verify', { response: device.assert(start.body) }, at, cookieOf(start)); assert.equal(reauth.status, 200);
+ assert.equal((await call('/passkeys/reauth/verify', { response: device.assert(start.body) }, at, cookieOf(start))).status, 401, 'reauth state is single use');
+ const anotherSession = auth.signToken(alice, keys.privateKey, config);
+ assert.equal((await call('/passkeys/remove', { password: pw, proof: reauth.body.proof, id: device.id }, anotherSession)).status, 401, 'proof bound to exact session');
+ const second = authenticator(); assert.equal((await enroll(second, at, { proof: reauth.body.proof })).status, 200);
+ assert.equal((await call('/passkeys/remove', { password: pw, proof: reauth.body.proof, id: second.id }, at)).status, 401, 'proof used once');
+ assert.equal((await call('/passkeys/remove', { password: pw, code: codes[1], id: device.id }, bt)).status, 401, 'other account cannot remove');
+ next();
+ start = await call('/passkeys/reauth/options', {}, at);
+ const expiredResponse = { response: device.assert(start.body) };
+ await db.prepare('UPDATE passkey_challenges SET expires_at = 0').run();
+ assert.equal((await call('/passkeys/reauth/verify', expiredResponse, at, cookieOf(start))).status, 401, 'reauth expiry');
+ start = await call('/passkeys/reauth/options', {}, at);
+ reauth = await call('/passkeys/reauth/verify', { response: device.assert(start.body) }, at, cookieOf(start));
+ assert.equal(reauth.status, 200);
+ await db.prepare('UPDATE passkey_challenges SET expires_at = 0').run();
+ assert.equal((await call('/passkeys/remove', { password: pw, proof: reauth.body.proof, id: device.id }, at)).status, 401, 'expired management proof');
+ assert.equal((await call('/passkeys', undefined, bt)).body.keys.length, 1, 'list only includes own keys');
+ const exported = await accountData.networkPart(db, alice.id);
+ assert.ok(exported.files.some(f => f.name === 'user_passkeys.json')); assert.ok(exported.files.some(f => f.name === 'passkey_challenges.json'));
+ assert.ok(!JSON.stringify(exported).includes(codes[2])); assert.ok(!JSON.stringify(exported).includes('public_key'));
+ await db.prepare("UPDATE site_settings SET value = 'off' WHERE key = 'mfa_enrollment'").run();
+ assert.equal((await assertion(device)).r.status, 200, 'existing keys work with flag off');
+ assert.equal((await login()).status, 202);
+ assert.equal((await call('/passkeys/remove', { password: pw, code: codes[1], id: device.id }, at)).status, 200);
+ assert.equal((await assertion(device)).r.status, 401, 'removed credential refused');
+ assert.equal((await call('/passkeys/remove', { password: pw, code: codes[2], id: second.id }, at)).status, 200);
+ assert.equal((await login()).status, 200, 'last key removed');
+ next();
+ // The real ADR-033 erasure path removes credentials and pending account-bound states.
+ await call('/passkeys/reauth/options', {}, bt);
+ const deletion = await accountData.scheduleDeletion(db, bob, { auth_time: Math.floor(now / 1000) }, bob.username);
+ await accountData.erase(db, await db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(deletion.body.deletion_id));
+ for (const table of ['user_passkeys', 'passkey_challenges']) assert.equal((await db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE user_id = ?`).get(bob.id)).n, 0);
+ assert.equal((await assertion(bobDevice)).r.status, 401, 'deleted account key refused');
+ console.log('Passkeys: real ES256 registration/sign-in, origin/RP/UV, expiry/replay, account isolation, second factor, one-use recovery/proof, removal, export and flag-off checks passed');
+ } finally { Date.now = realNow; server.close(); await globalThis.__ovNetworkTestDbClose?.(); }
+})().catch(err => { console.error('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; });
@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -56,7 +56,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'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', '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'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', '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
// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.
// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.