diff --git a/backend/migrations/1787900000000_add-passkeys.js b/backend/migrations/1787900000000_add-passkeys.js new file mode 100644 index 0000000..9845b8b --- /dev/null +++ b/backend/migrations/1787900000000_add-passkeys.js @@ -0,0 +1,97 @@ +exports.up = (pgm) => { + pgm.sql(` + -- A registered passkey (#37). Groundwork only: nothing reads these yet. + -- + -- Bound to the customer and deleted with them. Account deletion already + -- removes the personal data this sits beside, and a credential that + -- outlived its owner could authenticate as a customer who no longer exists. + -- Disabling an account (#33) is a different question and is deliberately not + -- a schema concern: a disabled customer keeps their credentials and is + -- refused at the authentication ceremony instead, so re-enabling them does + -- not mean re-registering every device. + CREATE TABLE IF NOT EXISTS customer_credentials ( + id SERIAL PRIMARY KEY, + customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE, + + -- The credential ID as base64url text rather than bytea. It arrives from + -- the browser in that form, is compared as an opaque string, and is never + -- interpreted here — storing bytes would mean encoding on write and + -- decoding on every read for no gain. + -- + -- Unique across the table, not merely per customer: a credential ID + -- identifies an authenticator, and the same one appearing under two + -- accounts means something has gone wrong rather than that two people + -- share a key. + credential_id TEXT NOT NULL UNIQUE, + + -- The COSE public key, base64url. Verified against, never parsed here. + public_key TEXT NOT NULL, + + -- BIGINT because the spec allows a 32-bit unsigned value, which overflows + -- a signed INTEGER at half its range. + -- + -- What to do when this fails to increase is NOT decided here. Many synced + -- passkeys report 0 forever, so "a regression means cloning" is wrong for + -- them and right for hardware keys. That policy belongs with the + -- authentication ceremony that enforces it (#39); this column only has to + -- be able to hold the value. + signature_counter BIGINT NOT NULL DEFAULT 0, + + -- How the authenticator can be reached: usb, nfc, ble, internal, hybrid. + -- A JSON array as text, because it is passed back to the browser verbatim + -- and never queried on. + transports TEXT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- Null until first used. Shown on the account page (#40) so a customer can + -- recognise which device a credential belongs to, which is the only way + -- they can tell two entries apart. + last_used_at TIMESTAMPTZ + ); + + -- Listing a customer's credentials is the common read, and revocation (#40) + -- has to scope by owner. + CREATE INDEX IF NOT EXISTS customer_credentials_customer_id_idx + ON customer_credentials (customer_id); + + -- An in-flight WebAuthn challenge (#37). + -- + -- A separate table rather than customer_tokens with a new kind, and the + -- reason is structural rather than tidiness: customer_tokens.customer_id is + -- NOT NULL, and an *authentication* challenge is issued before anyone is + -- identified. A discoverable-credential sign-in has no customer to attach + -- to at the moment the challenge is created, so it could not be stored + -- there without making that column nullable for every other kind of token. + CREATE TABLE IF NOT EXISTS webauthn_challenges ( + -- The challenge itself, base64url, as issued. Primary key because it is + -- the thing looked up, and unique by construction. + challenge TEXT PRIMARY KEY, + + -- Null for authentication, set for registration. Registration requires an + -- authenticated session — it is not a sign-up path — so that half always + -- knows whose it is. + customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE, + + -- 'registration' or 'authentication'. Not a CHECK constraint: the values + -- come from this codebase rather than from a request, and the project has + -- no enum types elsewhere. + kind TEXT NOT NULL, + + expires_at TIMESTAMPTZ NOT NULL + ); + + -- Expiry is swept by time, so the sweep reads this rather than the whole + -- table. Single use is enforced by deleting the row on consumption, which + -- needs no index beyond the primary key. + CREATE INDEX IF NOT EXISTS webauthn_challenges_expires_at_idx + ON webauthn_challenges (expires_at); + `); +}; + +exports.down = (pgm) => { + pgm.sql(` + DROP TABLE IF EXISTS webauthn_challenges; + DROP TABLE IF EXISTS customer_credentials; + `); +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index 1fd1d56..a0d9bb9 100755 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@anthropic-ai/sdk": "^0.122.0", + "@simplewebauthn/server": "^14.0.1", "@types/markdown-it": "^14.2.0", "bcryptjs": "^2.4.3", "cookie-parser": "^1.4.6", @@ -102,6 +103,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1304,6 +1306,12 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.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/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -2257,6 +2265,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "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/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -2280,6 +2294,260 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@peculiar/asn1-android": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.9.4.tgz", + "integrity": "sha512-SYHm4SoWSI0nRCoos6jpGusIqhPH9bbGBqv7ohlZ+H6BunrDzzQPk2ePgDuEUzV82OdvbLgtW4twUDwhU9P3YQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-asym-key": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-asym-key/-/asn1-asym-key-2.9.4.tgz", + "integrity": "sha512-s7SJfjcXlR3MYMngDTeMLCy3VjGaIxeEy905CQ0j09pDbeYhNBvBGA7r7cAvCZYkVFo9kVg9RAki0K2iUz7SGQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz", + "integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz", + "integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz", + "integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz", + "integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-rsa": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz", + "integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz", + "integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pfx": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz", + "integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", + "integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==", + "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.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz", + "integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz", + "integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-post-quantum": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-post-quantum/-/asn1-x509-post-quantum-2.9.4.tgz", + "integrity": "sha512-GD0k7BY3dsEIeai7C7bVRPddj/PzZu+sTawRPRxdHKbdy1f9b58WQyk0eFJdr28GShyLVTU8poyi4+bTAZfEtQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-asym-key": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "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.1.0", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.1.0.tgz", + "integrity": "sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-csr": "^2.9.4", + "@peculiar/asn1-ecc": "^2.9.4", + "@peculiar/asn1-pkcs9": "^2.9.4", + "@peculiar/asn1-rsa": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-post-quantum": "^2.9.4", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@simplewebauthn/server": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.0.1.tgz", + "integrity": "sha512-kjWgcm9NdSv+Tobo4Swxd1WnEawsBogpYFMjg9pKsVLPN3FdCmR6BipkL+DcPDgmQ7UKbbBZqRqn3E3YaA7NBw==", + "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": ">=20.0.0" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.12", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", @@ -2415,6 +2683,7 @@ "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -2575,6 +2844,7 @@ "integrity": "sha512-AYdtudzabjLZgVgRZmAnU8bAnVUXzuJX2IYHeSIiIHm68olD+LgQYCGWdtcNYnP0uq9c4S4NibVG3Ni7VbKW7Q==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -2721,6 +2991,7 @@ "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/types": "8.67.0", @@ -3090,6 +3361,7 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3207,6 +3479,20 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3424,6 +3710,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -4245,6 +4532,7 @@ "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4635,6 +4923,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -5556,6 +5845,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -6268,6 +6558,7 @@ "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz", "integrity": "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=20.0.0" } @@ -7102,6 +7393,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", @@ -7385,6 +7677,24 @@ ], "license": "MIT" }, + "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/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", @@ -7466,6 +7776,12 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, + "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/regexp-ast-analysis": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/regexp-ast-analysis/-/regexp-ast-analysis-0.7.1.tgz", @@ -8154,6 +8470,7 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8291,8 +8608,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.23.12", @@ -8313,6 +8629,24 @@ "fsevents": "~2.3.3" } }, + "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-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -8374,6 +8708,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8696,6 +9031,7 @@ "version": "4.5.4", "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/backend/package.json b/backend/package.json index 494afe1..02e1bb8 100755 --- a/backend/package.json +++ b/backend/package.json @@ -32,6 +32,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.122.0", + "@simplewebauthn/server": "^14.0.1", "@types/markdown-it": "^14.2.0", "bcryptjs": "^2.4.3", "cookie-parser": "^1.4.6", diff --git a/backend/src/db-kysely/schema.ts b/backend/src/db-kysely/schema.ts index 566b255..953aa47 100644 --- a/backend/src/db-kysely/schema.ts +++ b/backend/src/db-kysely/schema.ts @@ -9,6 +9,8 @@ export type Generated = T extends ColumnType ? ColumnType : ColumnType; +export type Int8 = ColumnType; + export type Json = JsonValue; export type JsonArray = JsonValue[]; @@ -71,6 +73,17 @@ export interface Checkouts { status: Generated; } +export interface CustomerCredentials { + created_at: Generated; + credential_id: string; + customer_id: number; + id: Generated; + last_used_at: Timestamp | null; + public_key: string; + signature_counter: Generated; + transports: string | null; +} + export interface Customers { analytics_consent: Generated; analytics_consent_at: Timestamp | null; @@ -212,6 +225,13 @@ export interface UploadLinks { token_hash: string; } +export interface WebauthnChallenges { + challenge: string; + customer_id: number | null; + expires_at: Timestamp; + kind: string; +} + export interface DB { admin_settings: AdminSettings; cart_items: CartItems; @@ -219,6 +239,7 @@ export interface DB { categories: Categories; checkout_items: CheckoutItems; checkouts: Checkouts; + customer_credentials: CustomerCredentials; customer_sessions: CustomerSessions; customer_tokens: CustomerTokens; customers: Customers; @@ -231,4 +252,5 @@ export interface DB { shipping_addresses: ShippingAddresses; tags: Tags; upload_links: UploadLinks; + webauthn_challenges: WebauthnChallenges; } diff --git a/backend/src/passkeys/relyingParty.ts b/backend/src/passkeys/relyingParty.ts new file mode 100644 index 0000000..8c594ac --- /dev/null +++ b/backend/src/passkeys/relyingParty.ts @@ -0,0 +1,111 @@ +/** + * Who this application is, as far as WebAuthn is concerned (#37). + * + * ## Why this is derived rather than written down + * + * The Relying Party ID is a domain, and **a credential is bound to it + * permanently**. A passkey registered against one RP ID cannot be used against + * another — there is no migration, no re-signing, and no way to carry one over. + * So the RP ID is the one piece of configuration that must never be wrong, and + * must never be a value someone remembered to change. + * + * It comes from `PUBLIC_URL`, which is the same value every customer-facing + * link is already built from. That makes the RP ID correct by construction in + * any environment where mail works, and wrong only in environments where the + * links were already wrong. + * + * ## The consequence worth stating plainly + * + * Each environment is a different Relying Party: + * + * | Environment | RP ID | Effect | + * | --- | --- | --- | + * | Local | `localhost` | A secure context by exception, so passkeys work | + * | QA | the QA hostname | Registered here, usable only here | + * | Production | the production hostname | Different credentials again | + * + * **QA can prove the flow and can never prove the credentials.** A passkey + * registered in QA will not sign in to production, and that is correct rather + * than a bug to work around. + * + * It also means **#313 destroys every passkey registered before it**. Moving to + * `redefined-designs.com` changes the RP ID, so credentials bound to + * `*.bermudalamb.synology.me` stop working at the cutover with no way back. + * This code needs no change when that happens — it follows `PUBLIC_URL` — but + * anyone who registered a passkey beforehand has to register it again. That is + * free today, because production is not live and no real customer holds one, + * and it stops being free the moment the shop opens. + */ + +/** Everything the ceremonies need to identify this Relying Party. */ +export interface RelyingParty { + /** The RP ID: a bare domain, no scheme and no port. */ + id: string; + /** Shown by the authenticator when it asks the customer to confirm. */ + name: string; + /** + * Origins a ceremony may legitimately come from. + * + * A list rather than one string because local development serves the app from + * two: Vite on 5173 during `npm run dev`, and the backend on 3000 when the + * built frontend is served by Express. Both are `localhost`, so both are the + * same Relying Party — only the port differs, and the port is not part of the + * RP ID. Deployed environments have exactly one. + */ + origins: string[]; +} + +export const RELYING_PARTY_NAME = 'Redefined Designs'; + +/** + * Local development, where `PUBLIC_URL` is legitimately unset. + * + * `envValidation` requires `PUBLIC_URL` only when SMTP is configured, so a local + * setup that cannot send mail does not have it — and refusing to start there + * would break every such setup to prevent nothing. `localhost` is a secure + * context by exception in every browser that implements WebAuthn, so this works + * without TLS. + */ +const LOCAL_ORIGINS = ['http://localhost:5173', 'http://localhost:3000']; + +/** + * The Relying Party for this environment. + * + * Takes the environment as an argument so it can be tested without touching + * `process.env`, and reads it on each call rather than at import time: the + * module would otherwise capture whatever was set when it was first required, + * which in tests is whatever the previous suite happened to leave behind. + * + * Throws on a `PUBLIC_URL` that is set but unparseable. That is a deployment + * that will also produce broken links in every email, so failing here is not + * the first thing to go wrong — it is the first thing to *say so*. + */ +export function relyingParty(env: NodeJS.ProcessEnv = process.env): RelyingParty { + const publicUrl = (env.PUBLIC_URL ?? '').trim(); + + if (publicUrl === '') { + return { id: 'localhost', name: RELYING_PARTY_NAME, origins: LOCAL_ORIGINS }; + } + + let parsed: URL; + try { + parsed = new URL(publicUrl); + } catch { + throw new Error( + `PUBLIC_URL is not a URL (${publicUrl}), so the WebAuthn Relying Party ID cannot be ` + + 'derived from it. Every passkey is bound permanently to that ID, so this is refused ' + + 'rather than guessed at.' + ); + } + + return { + // `hostname` rather than `host`: the RP ID is a domain and must not carry a + // port. `host` includes one when the URL has it, and an RP ID of + // "example.com:8443" matches nothing. + id: parsed.hostname, + name: RELYING_PARTY_NAME, + // `origin` normalises away any path, trailing slash or default port, which + // is exactly the string the browser will report. + origins: [parsed.origin] + }; +} diff --git a/backend/tests/integration/schemaLoss.integration.test.ts b/backend/tests/integration/schemaLoss.integration.test.ts index f69f68f..6e68234 100644 --- a/backend/tests/integration/schemaLoss.integration.test.ts +++ b/backend/tests/integration/schemaLoss.integration.test.ts @@ -42,7 +42,7 @@ describe('when the database loses its schema', () => { // The count and a few names, not the whole list: the point is that a reader // can tell at a glance this is a missing schema rather than a logic bug. - await expect(assertSchemaPresent('in a test')).rejects.toThrow(/Missing 18 of 18 tables/); + await expect(assertSchemaPresent('in a test')).rejects.toThrow(/Missing 20 of 20 tables/); await expect(assertSchemaPresent('in a test')).rejects.toThrow(/items/); await migrate(); diff --git a/backend/tests/integration/setup/testDb.ts b/backend/tests/integration/setup/testDb.ts index bf30b09..df7c7bb 100755 --- a/backend/tests/integration/setup/testDb.ts +++ b/backend/tests/integration/setup/testDb.ts @@ -37,6 +37,7 @@ const REQUIRED_TABLES = [ 'categories', 'checkout_items', 'checkouts', + 'customer_credentials', 'customer_sessions', 'customer_tokens', 'customers', @@ -48,7 +49,8 @@ const REQUIRED_TABLES = [ 'orders', 'shipping_addresses', 'tags', - 'upload_links' + 'upload_links', + 'webauthn_challenges' ] as const; /** Which of them the database does not currently have. */ @@ -151,6 +153,7 @@ export async function resetDb(): Promise { await testPool.query(` TRUNCATE TABLE item_drafts, upload_links, orders, checkout_items, checkouts, shipping_addresses, cart_items, carts, customer_tokens, customer_sessions, favorites, + webauthn_challenges, customer_credentials, customers, item_tags, item_images, items, tags, categories RESTART IDENTITY CASCADE `); diff --git a/backend/tests/unit/relyingParty.test.ts b/backend/tests/unit/relyingParty.test.ts new file mode 100644 index 0000000..610bffc --- /dev/null +++ b/backend/tests/unit/relyingParty.test.ts @@ -0,0 +1,75 @@ +import { relyingParty, RELYING_PARTY_NAME } from '../../src/passkeys/relyingParty'; + +/** + * The RP ID is the one value in this feature that cannot be corrected later: a + * passkey is bound to it permanently, and a wrong one is only discovered when a + * customer cannot sign in with a credential that no longer matches anything. + * + * So these assert the derivation itself rather than that the function returns + * something. Most of them are about what must *not* end up in the ID. + */ +describe('relyingParty', () => { + it('takes the RP ID from PUBLIC_URL', () => { + const rp = relyingParty({ PUBLIC_URL: 'https://redefined-designs.com' }); + + expect(rp.id).toBe('redefined-designs.com'); + expect(rp.origins).toEqual(['https://redefined-designs.com']); + expect(rp.name).toBe(RELYING_PARTY_NAME); + }); + + it('keeps a subdomain, which is a different Relying Party', () => { + // QA and production are separate RPs on purpose. A credential registered + // against one does not work against the other, and collapsing them to the + // registrable domain would silently make QA able to sign into production. + const qa = relyingParty({ PUBLIC_URL: 'https://qa-redefined-designs.bermudalamb.synology.me' }); + const prod = relyingParty({ PUBLIC_URL: 'https://redefined-designs.bermudalamb.synology.me' }); + + expect(qa.id).toBe('qa-redefined-designs.bermudalamb.synology.me'); + expect(prod.id).toBe('redefined-designs.bermudalamb.synology.me'); + expect(qa.id).not.toBe(prod.id); + }); + + it('strips the port, which is not part of an RP ID', () => { + // An RP ID of "example.com:8443" matches nothing, and the failure is a + // credential that cannot be used rather than an error at registration. + const rp = relyingParty({ PUBLIC_URL: 'https://example.com:8443' }); + + expect(rp.id).toBe('example.com'); + expect(rp.origins).toEqual(['https://example.com:8443']); + }); + + it('strips a path and a trailing slash from the origin', () => { + const rp = relyingParty({ PUBLIC_URL: 'https://example.com/shop/' }); + + expect(rp.id).toBe('example.com'); + // The browser reports an origin, never a path, so a stored path would never + // match what arrives. + expect(rp.origins).toEqual(['https://example.com']); + }); + + it.each(['', ' ', undefined])('falls back to localhost when PUBLIC_URL is %p', (value) => { + // envValidation requires PUBLIC_URL only when SMTP is configured, so a local + // setup that cannot send mail legitimately has none. + const rp = relyingParty(value === undefined ? {} : { PUBLIC_URL: value }); + + expect(rp.id).toBe('localhost'); + // Both local origins, because the app is served from Vite during dev and + // from Express once built, and they differ only by port. + expect(rp.origins).toEqual(['http://localhost:5173', 'http://localhost:3000']); + }); + + it('refuses a PUBLIC_URL that is not a URL rather than guessing', () => { + // Guessing here would bind every passkey to something nobody chose, and the + // binding is permanent. + expect(() => relyingParty({ PUBLIC_URL: 'redefined-designs.com' })).toThrow( + /Relying Party ID cannot be derived/ + ); + }); + + it('reads the environment on each call rather than at import', () => { + // Captured at import, this would hold whatever the first suite to require + // the module happened to have set. + expect(relyingParty({ PUBLIC_URL: 'https://one.example' }).id).toBe('one.example'); + expect(relyingParty({ PUBLIC_URL: 'https://two.example' }).id).toBe('two.example'); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 85a934a..4045772 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@ant-design/icons": "^5.4.0", + "@simplewebauthn/browser": "^14.0.0", "@uiw/react-md-editor": "^4.0.4", "antd": "^5.20.6", "react": "^18.3.1", @@ -37,6 +38,9 @@ "vite": "^5.4.0", "vite-plugin-istanbul": "^6.0.2", "vitest": "^2.1.9" + }, + "engines": { + "node": ">=20.9.0" } }, "node_modules/@ant-design/colors": { @@ -157,6 +161,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1678,6 +1683,12 @@ "win32" ] }, + "node_modules/@simplewebauthn/browser": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-14.0.0.tgz", + "integrity": "sha512-1odWVqeEBTl7lJ9zMKLEsmTlnyrDO5iRcTvfMKKk1WThUnp/i8JJdffdj2icP+tty159s4PgwE3BiMoEW9NFow==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1804,6 +1815,7 @@ "version": "18.3.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1868,6 +1880,7 @@ "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/types": "8.67.0", @@ -2341,6 +2354,7 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2751,6 +2765,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -3199,7 +3214,8 @@ "node_modules/dayjs": { "version": "1.11.21", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", - "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==" + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "peer": true }, "node_modules/debug": { "version": "4.4.3", @@ -3628,6 +3644,7 @@ "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7133,6 +7150,7 @@ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", @@ -7236,6 +7254,7 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8041,6 +8060,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -8052,6 +8072,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -9396,6 +9417,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9629,6 +9651,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -10185,6 +10208,7 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index 0ab671a..121d3f7 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@ant-design/icons": "^5.4.0", + "@simplewebauthn/browser": "^14.0.0", "@uiw/react-md-editor": "^4.0.4", "antd": "^5.20.6", "react": "^18.3.1",