openvibers/openvibe.network Commits

Add pilot passkeys for Network sign-in and second factors (#772)

OP OpenVibers committed 3 days ago · dc3fcf4b01 · 23 files, +886 −15

* Add pilot passkeys for Network sign-in and second-factor authentication

* Check passkey proof expiry and isolation between sessions

Changes

ddiff --git a/docs/security/passkeys.md b/docs/security/passkeys.md
nnew file mode 100644
iindex 00000000..52b34697
---- /dev/null
++++ b/docs/security/passkeys.md
@@@ -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.
ddiff --git a/migrations/0067_passkeys.sql b/migrations/0067_passkeys.sql
nnew file mode 100644
iindex 00000000..7e87c43f
---- /dev/null
++++ b/migrations/0067_passkeys.sql
@@@ -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);
ddiff --git a/package-lock.json b/package-lock.json
iindex 6bf5909b..b7a0618f 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 39977429..8720a2dd 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/public/js/login.js b/public/js/login.js
iindex f528dd32..cbd6d50e 100644
---- a/public/js/login.js
++++ b/public/js/login.js
@@@ -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(() => {});
++}
ddiff --git a/public/js/my/mfa.js b/public/js/my/mfa.js
iindex 49ac4fa8..25ba192b 100644
---- a/public/js/my/mfa.js
++++ b/public/js/my/mfa.js
@@@ -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 = '';
ddiff --git a/public/js/my/passkeys.js b/public/js/my/passkeys.js
nnew file mode 100644
iindex 00000000..0cd4380e
---- /dev/null
++++ b/public/js/my/passkeys.js
@@@ -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. */ }
++}
ddiff --git a/public/js/my/profile.js b/public/js/my/profile.js
iindex 70105b9f..586a39ee 100644
---- a/public/js/my/profile.js
++++ b/public/js/my/profile.js
@@@ -139,6 +139,7 @@ async function loadUser() {
loadLinked();
loadSessions();
loadMfaSettings();
++ loadPasskeySettings();
loadApps();
loadBlocks();
loadAnonInfo();
ddiff --git a/public/js/passkeys.js b/public/js/passkeys.js
nnew file mode 100644
iindex 00000000..e34da560
---- /dev/null
++++ b/public/js/passkeys.js
@@@ -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 });
++ },
++};
ddiff --git a/public/login.html b/public/login.html
iindex 689fe508..2897294a 100644
---- a/public/login.html
++++ b/public/login.html
@@@ -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>
ddiff --git a/public/my.html b/public/my.html
iindex 628fb27f..0435c845 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -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>
ddiff --git a/scripts/probe-passkeys.cjs b/scripts/probe-passkeys.cjs
nnew file mode 100644
iindex 00000000..3fdb1f15
---- /dev/null
++++ b/scripts/probe-passkeys.cjs
@@@ -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; });
ddiff --git a/server/auth/mfa.js b/server/auth/mfa.js
iindex 1219fed6..84a4cd31 100644
---- a/server/auth/mfa.js
++++ b/server/auth/mfa.js
@@@ -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 };
ddiff --git a/server/auth/passkeys.js b/server/auth/passkeys.js
nnew file mode 100644
iindex 00000000..e5c8c237
---- /dev/null
++++ b/server/auth/passkeys.js
@@@ -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 };
ddiff --git a/server/auth/revocation.js b/server/auth/revocation.js
iindex fc456954..bec14df5 100644
---- a/server/auth/revocation.js
++++ b/server/auth/revocation.js
@@@ -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);
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex dc778d8c..a4c83c25 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -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 ─────────────────────────────────────────────────
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 4b8d7a97..a73c4dc3 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -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);
ddiff --git a/server/index.js b/server/index.js
iindex 812dc6d4..dcfb6195 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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.
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex c4cf216d..b2ce5ff6 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['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.
ddiff --git a/test/helpers/passkey-authenticator.js b/test/helpers/passkey-authenticator.js
nnew file mode 100644
iindex 00000000..a9efe839
---- /dev/null
++++ b/test/helpers/passkey-authenticator.js
@@@ -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 };
ddiff --git a/test/my-modules.test.js b/test/my-modules.test.js
iindex d113a19b..5f5b6245 100644
---- a/test/my-modules.test.js
++++ b/test/my-modules.test.js
@@@ -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();
// 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]);
ddiff --git a/test/passkeys.test.js b/test/passkeys.test.js
nnew file mode 100644
iindex 00000000..567c432d
---- /dev/null
++++ b/test/passkeys.test.js
@@@ -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; });
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 9c55271c..91f054a5 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -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.