Compare commits

...
4 Commits
Author SHA1 Message Date
shuaiplus 8e5d9e294b fix: update sharp to 0.35.0 2026-07-23 18:28:00 +08:00
shuaiplus 6ffdf05dc6 fix: enable official desktop browser integration settings
Return Bitwarden's desktop-ui-settings-dialog feature state so supported desktop clients use the official settings dialog with browser integration controls.

Fixes #315
2026-07-23 01:09:44 +08:00
shuaiplus 82d9f61163 fix: handle unavailable browser cryptography
Detect insecure or unsupported browser contexts before account registration and show localized HTTPS guidance instead of leaking a SubtleCrypto runtime error.

Fixes #320
2026-07-23 00:54:52 +08:00
zu1kandshuaiplus f761fffd58 fix: align WebAuthn connectors with Bitwarden clients
Add official-compatible mobile and desktop connector flows, preserve exact .html asset paths, and cover the protocol and framing behavior with regression tests.

Fixes #326
2026-07-23 00:39:34 +08:00
31 changed files with 1572 additions and 215 deletions
+171 -134
View File
@@ -1210,9 +1210,9 @@
} }
}, },
"node_modules/@img/sharp-darwin-arm64": { "node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5", "version": "0.35.0",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.0.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "integrity": "sha512-ZgaYEwaj+lx/5n4W8GmZ2IYz0PQHjN5eqRcfijWGB+2Aq7ZInZGa0qJyAn6DEtyLuWHRSrmWOqT9q3qqTBvmUQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1223,19 +1223,19 @@
"darwin" "darwin"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4" "@img/sharp-libvips-darwin-arm64": "1.3.0"
} }
}, },
"node_modules/@img/sharp-darwin-x64": { "node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5", "version": "0.35.0",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.0.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "integrity": "sha512-c1z9LFpKB0slQW3RchwBE8iSVzGp70TNjUUO9k4BZwwW4HH7JBGHeIy4b+kk4n/kcBASb9evKCE3/7Slmslgiw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1246,19 +1246,39 @@
"darwin" "darwin"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4" "@img/sharp-libvips-darwin-x64": "1.3.0"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.0",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.0.tgz",
"integrity": "sha512-Li2KTev0H90kEtnJHkI9xQojXt1AqWmFBMXiPw5kqd1jQgP7gi5HVK/qC5Rmh/59NuAwUuPzzPITmX22NomYYQ==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.0"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@img/sharp-libvips-darwin-arm64": { "node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.0.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "integrity": "sha512-EKbmBKtyTH+GPFDRw2TgK2oV6hyxxlJVIar4hoTYSNmIwipgMFdxPQqR392GmfdsPGWga0mCFN1cCKjRb9cljw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1273,9 +1293,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-darwin-x64": { "node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.0.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "integrity": "sha512-Pl2OmOvrJ42adUllESxBsG54PfXLo1OYg9i3c5/5Ln/qJ0gZuTM9YMhQJPIbXqwidLRc/c2zuHt4RsrymmNv7A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1290,9 +1310,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-arm": { "node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.0.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "integrity": "sha512-A8UpHoUDW4DwnXoV6+q3C1s7QLRAHtPDEjWuNZjwHMyoCNZnm0GeNN8ls9f/bsEYTRQRW96C/n34XJQHJ2fT7A==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1310,9 +1330,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-arm64": { "node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.0.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "integrity": "sha512-C0SqjoFKnszqa44EQ7xoaT48nnO0lOyXEULfXMWi8krrjOPGYkeK30Okzla6ATbBYsyZ0ySinK0FVkpv3DwzfQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1330,9 +1350,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-ppc64": { "node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.0.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", "integrity": "sha512-WOpkVxAjFd369iaIzEgNRreFD+gWdUMIGD5zplhNKNeqS6mm5dac3q2AFyCBmzYoAdouzZvRBgxy4z8QHZb4/A==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -1350,9 +1370,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-riscv64": { "node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.0.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", "integrity": "sha512-DRWw0mOHusrCCuw2rqP87oLg6PGlkomVDFqw2hIwsSfwWpu4k3XLcBPaKKl6ct/GtL/cwNkgwjV/tc0Mqht3VA==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -1370,9 +1390,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-s390x": { "node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.0.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "integrity": "sha512-9APy+nFWhHS+kzLgWZfLcyrUd7YqnAQVa4BPOo4xkoHpdoktOAPG4cEr9+Jpl0TtqfVmcMJimNL5qNTyyOHZNA==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -1390,9 +1410,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-x64": { "node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.0.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "integrity": "sha512-y9RNUYDe2A1UAdhLyfeOodGRszQdaEoe4nfOpp/sNVPl2CWIcUyFaDoCh4vPLPxu19803j2naLqZup2WxDXCLA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1410,9 +1430,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linuxmusl-arm64": { "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.0.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "integrity": "sha512-cC1wkC0Mlucd0KSiGrLkJnB/ZqPvZCntc/Lk7ZnYO5ZSbF2euNek4Xvxafojq+wN1q/W0eprdpUIjUr/EV2PBg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1430,9 +1450,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linuxmusl-x64": { "node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.0.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "integrity": "sha512-LiYMhUZicB1QG//+RvmYZpXJO8fYRENfp+MZUCnG9aw+AKvGAy9gPaCnuwsPcBFs8EV66M0NNxj9VHcNklE8zw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1450,9 +1470,9 @@
} }
}, },
"node_modules/@img/sharp-linux-arm": { "node_modules/@img/sharp-linux-arm": {
"version": "0.34.5", "version": "0.35.0",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.0.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "integrity": "sha512-VVlpEWwizEFIOom0zdoeKuO5nuTswzVE5uHcBNvHzmeHUpNFajY3HFfbQ+zIH4E2kVaZ/yVxmsShW56TtEy4uA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1466,19 +1486,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4" "@img/sharp-libvips-linux-arm": "1.3.0"
} }
}, },
"node_modules/@img/sharp-linux-arm64": { "node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5", "version": "0.35.0",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.0.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "integrity": "sha512-4+4XHLNT5wDT0roYlHTEmH9lDKt0acf9Tv+3hM3iceOirkxrR404/3WjAYZ9F9CkHrxeRcGLJXbi4vluMZ9O+A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1492,19 +1512,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4" "@img/sharp-libvips-linux-arm64": "1.3.0"
} }
}, },
"node_modules/@img/sharp-linux-ppc64": { "node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5", "version": "0.35.0",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.0.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", "integrity": "sha512-N3hzbEpUTJC8pWpPVJvgzGxM+so/MAXc8O2s/53B0LL9ZGpfXpME7Wizkc5d/8fRBlBtkDjzoZGDCqqNDHqLEw==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -1518,19 +1538,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4" "@img/sharp-libvips-linux-ppc64": "1.3.0"
} }
}, },
"node_modules/@img/sharp-linux-riscv64": { "node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5", "version": "0.35.0",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.0.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", "integrity": "sha512-l6vmKVPnbS0RhVMbyxP5meAARsbhCnBN4fy31qz0+3a6Rv4jEqfzDrT89y6ZPkCi0AJGnwp2En528yXo401Hpw==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -1544,19 +1564,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4" "@img/sharp-libvips-linux-riscv64": "1.3.0"
} }
}, },
"node_modules/@img/sharp-linux-s390x": { "node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5", "version": "0.35.0",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.0.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "integrity": "sha512-MYlMiPFiv/EKPAHnp3yNZ9AAWFsxga9c5Bkc6wkar6bqzHLlkGVJHRm0u1ei+VXnZxp3Mz9MG9ZIsI8vSOf3sQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -1570,19 +1590,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4" "@img/sharp-libvips-linux-s390x": "1.3.0"
} }
}, },
"node_modules/@img/sharp-linux-x64": { "node_modules/@img/sharp-linux-x64": {
"version": "0.34.5", "version": "0.35.0",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.0.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "integrity": "sha512-TYaItB5oj1ioXjhyn2xrR208vf+YuIIcHptQWRRaBmFhvIvL9D72DXN8w75xup0KXA8UdEAhQ9Qb2S49FD/9Cw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1596,19 +1616,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4" "@img/sharp-libvips-linux-x64": "1.3.0"
} }
}, },
"node_modules/@img/sharp-linuxmusl-arm64": { "node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5", "version": "0.35.0",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.0.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "integrity": "sha512-DSTb6ijQzqe6DdAaOBVqJ/SYf1vO8EW5bK6X6LRXufEBebf2722VCdvBUtZ3rtV0x2ApfPNDy/p7LrrjaWjiyQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1622,19 +1642,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4" "@img/sharp-libvips-linuxmusl-arm64": "1.3.0"
} }
}, },
"node_modules/@img/sharp-linuxmusl-x64": { "node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5", "version": "0.35.0",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.0.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "integrity": "sha512-K7ykQ+26Rt6+4BTU80AuGgTPIYX86UxiAKT4rcXX/WNTo7k1ZxpKz+TguHnwVpCqQK3B5PK0vZ0ZBe6nz/ib1w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1648,39 +1668,56 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4" "@img/sharp-libvips-linuxmusl-x64": "1.3.0"
} }
}, },
"node_modules/@img/sharp-wasm32": { "node_modules/@img/sharp-wasm32": {
"version": "0.34.5", "version": "0.35.0",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.0.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "integrity": "sha512-9woLIFORERCr+6cWu87dQ22J34EExkhc73U1kZW0c+RclQqWetoodByp4dWZ/hN8/KVmTRAx2HOnUwib8AwZdA==",
"cpu": [
"wasm32"
],
"dev": true, "dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/runtime": "^1.7.0" "@emnapi/runtime": "^1.11.0"
}, },
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.0",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.0.tgz",
"integrity": "sha512-t+kie1TOyaDM6Dho+f+y0VqIUNhYQaKCUahuZVi0E0frgdiaOaPsDxDW3wfKacUdaNBCnK/ZDBMg33ydvHj8uA==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.0"
},
"engines": {
"node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@img/sharp-win32-arm64": { "node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5", "version": "0.35.0",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.0.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", "integrity": "sha512-M5eKxug0dabbaWgFKvPa3odNs2OpaP+81NASfGKkt4GcYXpNhSu7CaeYxWkLNV6vHmUp4hnCxnxrUyhUJhXbKA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1691,16 +1728,16 @@
"win32" "win32"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@img/sharp-win32-ia32": { "node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5", "version": "0.35.0",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.0.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "integrity": "sha512-z0+pZ03QCDvdVN0Ez9IX/yjWC19ikMlXrmdYMwYNLTh2BLPx3hXWPvyqWfquZ0BTO9O6GVOjIVoTcyyacMnWlQ==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -1711,16 +1748,16 @@
"win32" "win32"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@img/sharp-win32-x64": { "node_modules/@img/sharp-win32-x64": {
"version": "0.34.5", "version": "0.35.0",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.0.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "integrity": "sha512-feNnlz5ZHKr0MY1LPHvZQyJeBkbo4ctsn0D8FvA53VTw5TC63rfEL2UrWbkSBR19htSE7Mw78xYVwdJqoMWVHw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1731,7 +1768,7 @@
"win32" "win32"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@@ -4395,9 +4432,9 @@
} }
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.8.3", "version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
@@ -4408,48 +4445,48 @@
} }
}, },
"node_modules/sharp": { "node_modules/sharp": {
"version": "0.34.5", "version": "0.35.0",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.0.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "integrity": "sha512-BqvG5XbwPZ4NV0DK90d86leEECMsoa8bO0nqnKWlBDYxri4GJ7c4EDInaF6q20lTh/mATmnDIKWJFfXnoVfH5g==",
"dev": true, "dev": true,
"hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@img/colour": "^1.0.0", "@img/colour": "^1.1.0",
"detect-libc": "^2.1.2", "detect-libc": "^2.1.2",
"semver": "^7.7.3" "semver": "^7.8.4"
}, },
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-arm64": "0.35.0",
"@img/sharp-darwin-x64": "0.34.5", "@img/sharp-darwin-x64": "0.35.0",
"@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-freebsd-wasm32": "0.35.0",
"@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-darwin-arm64": "1.3.0",
"@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.3.0",
"@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.3.0",
"@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.3.0",
"@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.3.0",
"@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.3.0",
"@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.3.0",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linux-x64": "1.3.0",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.0",
"@img/sharp-linux-arm": "0.34.5", "@img/sharp-libvips-linuxmusl-x64": "1.3.0",
"@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-arm": "0.35.0",
"@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-arm64": "0.35.0",
"@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-ppc64": "0.35.0",
"@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-riscv64": "0.35.0",
"@img/sharp-linux-x64": "0.34.5", "@img/sharp-linux-s390x": "0.35.0",
"@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linux-x64": "0.35.0",
"@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.35.0",
"@img/sharp-wasm32": "0.34.5", "@img/sharp-linuxmusl-x64": "0.35.0",
"@img/sharp-win32-arm64": "0.34.5", "@img/sharp-webcontainers-wasm32": "0.35.0",
"@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-arm64": "0.35.0",
"@img/sharp-win32-x64": "0.34.5" "@img/sharp-win32-ia32": "0.35.0",
"@img/sharp-win32-x64": "0.35.0"
} }
}, },
"node_modules/simple-code-frame": { "node_modules/simple-code-frame": {
+7 -1
View File
@@ -15,6 +15,11 @@
"domains:sync": "node scripts/sync-global-domains.mjs", "domains:sync": "node scripts/sync-global-domains.mjs",
"i18n": "node scripts/i18n-validate.cjs", "i18n": "node scripts/i18n-validate.cjs",
"i18n:validate": "node scripts/i18n-validate.cjs", "i18n:validate": "node scripts/i18n-validate.cjs",
"test:config-compatibility": "tsx --test scripts/config-compatibility.test.ts",
"test:web-crypto": "tsx --test scripts/web-crypto-availability.test.ts",
"test:webauthn-mobile": "node --test scripts/webauthn-mobile-connector.test.mjs",
"test:webauthn-connector": "node --test scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts",
"test:webauthn-connectors": "node --test scripts/webauthn-mobile-connector.test.mjs scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts",
"deploy": "wrangler deploy", "deploy": "wrangler deploy",
"deploy:kv": "node scripts/ensure-kv.cjs && wrangler deploy -c wrangler.kv.toml", "deploy:kv": "node scripts/ensure-kv.cjs && wrangler deploy -c wrangler.kv.toml",
"deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo" "deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo"
@@ -46,7 +51,8 @@
"undici": ">=7.28.0", "undici": ">=7.28.0",
"@babel/core": ">=7.29.6", "@babel/core": ">=7.29.6",
"esbuild": ">=0.28.1", "esbuild": ">=0.28.1",
"ws": "8.21.0" "ws": "8.21.0",
"sharp": "0.35.0"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20260630.1", "@cloudflare/workers-types": "^4.20260630.1",
+12
View File
@@ -0,0 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildConfigResponse } from '../src/config-response';
test('config enables the official Bitwarden desktop settings dialog', () => {
const body = buildConfigResponse('https://vault.example.test');
assert.equal(body.featureStates['desktop-ui-settings-dialog'], true);
assert.equal(body.environment.vault, 'https://vault.example.test');
assert.equal(body.object, 'config');
});
+84
View File
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerAccount } from '../webapp/src/lib/api/auth';
import {
requireWebCrypto,
WebCryptoUnavailableError,
} from '../webapp/src/lib/crypto';
const supportedCrypto = {
subtle: {
importKey: () => Promise.reject(new Error('not used by capability checks')),
},
getRandomValues: <T>(array: T): T => array,
} as unknown as Crypto;
function restoreGlobalProperty(name: string, descriptor: PropertyDescriptor | undefined): void {
if (descriptor) {
Object.defineProperty(globalThis, name, descriptor);
return;
}
delete (globalThis as unknown as Record<string, unknown>)[name];
}
test('Web Crypto guard rejects insecure browser contexts', () => {
assert.throws(
() => requireWebCrypto({ crypto: supportedCrypto, isSecureContext: false }),
WebCryptoUnavailableError
);
});
test('Web Crypto guard rejects secure contexts without SubtleCrypto', () => {
const cryptoWithoutSubtle = {
getRandomValues: <T>(array: T): T => array,
} as unknown as Crypto;
assert.throws(
() => requireWebCrypto({ crypto: cryptoWithoutSubtle, isSecureContext: true }),
WebCryptoUnavailableError
);
});
test('Web Crypto guard accepts a secure supported browser', () => {
assert.equal(
requireWebCrypto({ crypto: supportedCrypto, isSecureContext: true }),
supportedCrypto
);
});
test('registration returns an actionable error without contacting the backend', async () => {
const cryptoDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto');
const secureContextDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'isSecureContext');
const fetchDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'fetch');
let fetchCalled = false;
Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true });
Object.defineProperty(globalThis, 'isSecureContext', { value: false, configurable: true });
Object.defineProperty(globalThis, 'fetch', {
configurable: true,
value: async () => {
fetchCalled = true;
return new Response(null, { status: 500 });
},
});
try {
const result = await registerAccount({
email: 'first@example.test',
name: 'First Admin',
password: 'correct horse battery staple',
fallbackIterations: 600_000,
});
assert.deepEqual(result, {
ok: false,
message: 'Secure browser cryptography is unavailable. Open NodeWarden over HTTPS in a supported browser.',
});
assert.equal(fetchCalled, false);
} finally {
restoreGlobalProperty('crypto', cryptoDescriptor);
restoreGlobalProperty('isSecureContext', secureContextDescriptor);
restoreGlobalProperty('fetch', fetchDescriptor);
}
});
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import type { Env } from '../src/types';
import { getConfiguredWebAuthnAllowedOrigins } from '../src/utils/origins';
import { applyCors, handleCors } from '../src/utils/response';
const env = {} as Env;
test('only the iframe connector drops anti-framing headers', () => {
const connectorRequest = new Request('https://vault.example.test/webauthn-connector.html');
const connector = applyCors(connectorRequest, new Response('<!doctype html>'), env);
assert.equal(connector.headers.get('X-Frame-Options'), null);
assert.doesNotMatch(connector.headers.get('Content-Security-Policy') || '', /frame-ancestors/);
assert.match(connector.headers.get('Content-Security-Policy') || '', /script-src 'self'/);
for (const path of ['/', '/webauthn-fallback-connector.html', '/webauthn-mobile-connector.html']) {
const request = new Request(`https://vault.example.test${path}`);
const response = applyCors(request, new Response('<!doctype html>'), env);
assert.equal(response.headers.get('X-Frame-Options'), 'DENY');
assert.match(response.headers.get('Content-Security-Policy') || '', /frame-ancestors 'none'/);
}
});
test('official Bitwarden desktop origin receives credentialed CORS', () => {
assert.ok(getConfiguredWebAuthnAllowedOrigins(env).includes('bw-desktop-file://bundle'));
const preflight = handleCors(new Request('https://vault.example.test/api/sync', {
method: 'OPTIONS',
headers: {
Origin: 'bw-desktop-file://bundle',
'Access-Control-Request-Headers': 'authorization, content-type',
},
}), env);
assert.equal(preflight.headers.get('Access-Control-Allow-Origin'), 'bw-desktop-file://bundle');
assert.equal(preflight.headers.get('Access-Control-Allow-Credentials'), 'true');
});
test('Worker assets preserve exact official connector .html paths', async () => {
for (const configUrl of [
new URL('../wrangler.toml', import.meta.url),
new URL('../wrangler.kv.toml', import.meta.url),
]) {
const config = await readFile(configUrl, 'utf8');
const assetsSection = config.match(/\[assets\]([\s\S]*?)(?=\n\[|$)/)?.[1] || '';
assert.match(assetsSection, /^\s*html_handling\s*=\s*"none"\s*$/m);
}
});
+126
View File
@@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import {
buildCredentialData,
normalizePublicKeyOptions,
parseConnectorRequest,
resolveParentChannel,
} from '../webapp/public/webauthn-connector.js';
function encodeBase64Utf8(value) {
return Buffer.from(value, 'utf8').toString('base64');
}
const publicKeyOptions = {
challenge: 'AQID',
allowCredentials: [{ id: 'BAUG', type: 'public-key', transports: ['usb'] }],
timeout: 60000,
rpId: 'vault.example.test',
};
test('parses the official desktop/browser V1 connector request', () => {
const params = new URLSearchParams({
data: encodeBase64Utf8(JSON.stringify(publicKeyOptions)),
parent: encodeURIComponent('file:///C:/Program Files/Bitwarden/resources/app/index.html'),
btnText: encodeURIComponent('Read security key'),
btnAwaitingInteractionText: encodeURIComponent('Awaiting security key interaction...'),
v: '1',
});
const request = parseConnectorRequest(params);
assert.equal(request.parentUrl, 'file:///C:/Program Files/Bitwarden/resources/app/index.html');
assert.equal(request.parentProtocol, 'file:');
assert.deepEqual(JSON.parse(request.webauthnJson), publicKeyOptions);
assert.equal(request.buttonText, 'Read security key');
assert.equal(request.awaitingText, 'Awaiting security key interaction...');
});
test('keeps V2 parsing compatible with the shared official connector protocol', () => {
const params = new URLSearchParams({
data: encodeBase64Utf8(JSON.stringify({ data: JSON.stringify(publicKeyOptions) })),
parent: encodeURIComponent('chrome-extension://nngceckbapebfimnlniiiahkandclblb/popup/index.html'),
v: '2',
});
assert.deepEqual(JSON.parse(parseConnectorRequest(params).webauthnJson), publicKeyOptions);
});
test('normalizes WebAuthn challenge and allowed credential IDs', () => {
const normalized = normalizePublicKeyOptions(JSON.stringify(publicKeyOptions));
assert.deepEqual(Array.from(normalized.challenge), [1, 2, 3]);
assert.deepEqual(Array.from(normalized.allowCredentials[0].id), [4, 5, 6]);
});
test('emits the exact assertion shape consumed by official Bitwarden clients', () => {
const output = JSON.parse(buildCredentialData({
id: 'credential-id',
rawId: Uint8Array.from([1, 2, 3]).buffer,
type: 'public-key',
getClientExtensionResults: () => ({ appid: false }),
response: {
authenticatorData: Uint8Array.from([4, 5]).buffer,
clientDataJSON: Uint8Array.from([6, 7]).buffer,
signature: Uint8Array.from([8, 9]).buffer,
},
}));
assert.deepEqual(output, {
id: 'credential-id',
rawId: 'AQID',
type: 'public-key',
extensions: { appid: false },
response: {
authenticatorData: 'BAU',
clientDataJson: 'Bgc',
signature: 'CAk',
},
});
});
test('accepts legacy file and current official desktop parent origins', () => {
assert.deepEqual(resolveParentChannel({
parentProtocol: 'file:',
parentUrl: 'file:///C:/Bitwarden/index.html',
}, 'https://vault.example.test'), {
eventOrigin: 'null',
targetOrigin: 'file:///C:/Bitwarden/index.html',
});
assert.deepEqual(resolveParentChannel({
parentProtocol: 'bw-desktop-file:',
parentUrl: 'bw-desktop-file://bundle/index.html',
}, 'https://vault.example.test'), {
eventOrigin: 'bw-desktop-file://bundle',
targetOrigin: 'bw-desktop-file://bundle/index.html',
});
});
test('accepts configured official extension origins and rejects arbitrary parents', () => {
const extension = 'chrome-extension://nngceckbapebfimnlniiiahkandclblb';
assert.deepEqual(resolveParentChannel({
parentProtocol: 'chrome-extension:',
parentUrl: `${extension}/popup/index.html`,
}, 'https://vault.example.test', [extension]), {
eventOrigin: extension,
targetOrigin: extension,
});
assert.throws(() => resolveParentChannel({
parentProtocol: 'https:',
parentUrl: 'https://attacker.example/frame',
}, 'https://vault.example.test', []), /Untrusted parent/);
});
test('uses the official postMessage message contract and iframe-sized fallback styling', async () => {
const [html, source, viteConfig] = await Promise.all([
readFile(new URL('../webapp/public/webauthn-connector.html', import.meta.url), 'utf8'),
readFile(new URL('../webapp/public/webauthn-connector.js', import.meta.url), 'utf8'),
readFile(new URL('../webapp/vite.config.ts', import.meta.url), 'utf8'),
]);
assert.match(html, /id="webauthn-button"/);
assert.match(html, /min-height:\s*40px/);
assert.match(html, /background:\s*#2563eb/);
assert.match(source, /post\('info\|ready'\)/);
assert.match(source, /post\(`success\|\$\{buildCredentialData\(credential\)\}`\)/);
assert.match(source, /post\(`error\|\$\{browserErrorMessage\(error\)\}`\)/);
assert.match(source, /event\.data === 'stop'/);
assert.match(source, /event\.data === 'start'/);
assert.match(viteConfig, /endsWith\('-connector\.html'\)/);
});
+135
View File
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import {
base64UrlFromBuffer,
buildCallbackUrl,
buildCredentialData,
decodeBase64Utf8,
normalizePublicKeyOptions,
parseConnectorRequest,
resolveMobileCallbackUri,
} from '../webapp/public/webauthn-mobile-connector.js';
function encodeBase64Utf8(value) {
return Buffer.from(value, 'utf8').toString('base64');
}
function v2Search(payload, extra = '') {
return `?data=${encodeURIComponent(encodeBase64Utf8(JSON.stringify(payload)))}&parent=bitwarden%3A__webauthn-callback&v=2${extra}`;
}
const assertionOptions = {
challenge: 'AQID-v8',
rpId: 'vault.example.com',
timeout: 60000,
userVerification: 'preferred',
allowCredentials: [{ id: 'BAUGBwg', type: 'public-key', transports: ['internal'] }],
};
test('parses the current Bitwarden Android V2 connector payload', () => {
const request = parseConnectorRequest(v2Search({
btnReturnText: 'Return to app', btnText: 'Authenticate', data: JSON.stringify(assertionOptions),
headerText: 'Verify your identity', mobile: true,
}, '&client=mobile&deeplinkScheme=bitwarden'), 'vault.example.com');
assert.equal(request.callbackUri, 'bitwarden://webauthn-callback');
assert.equal(request.headerText, 'Verify your identity');
assert.equal(request.buttonText, 'Authenticate');
assert.equal(request.returnButtonText, 'Return to app');
assert.deepEqual(JSON.parse(request.webauthnJson), assertionOptions);
});
test('uses callbackUri only as a signal and never as the redirect target', () => {
const trustedLooking = parseConnectorRequest(v2Search({
callbackUri: 'https://bitwarden.eu/webauthn-callback', data: assertionOptions,
}).replace('&parent=bitwarden%3A__webauthn-callback', ''));
const attacker = parseConnectorRequest(v2Search({
callbackUri: 'https://attacker.example/capture', data: assertionOptions,
}).replace('&parent=bitwarden%3A__webauthn-callback', ''));
assert.equal(trustedLooking.callbackUri, 'bitwarden://webauthn-callback');
assert.equal(attacker.callbackUri, 'bitwarden://webauthn-callback');
});
test('treats any non-HTTPS deeplinkScheme as the fixed Bitwarden custom scheme', () => {
const request = parseConnectorRequest(v2Search({ mobile: true, data: assertionOptions }, '&deeplinkScheme=untrusted'));
assert.equal(request.callbackUri, 'bitwarden://webauthn-callback');
});
test('supports Android custom-scheme and official HTTPS App Link callbacks', () => {
const payload = { mobile: true, data: assertionOptions };
const custom = parseConnectorRequest(v2Search(payload, '&client=mobile&deeplinkScheme=bitwarden'));
const eu = parseConnectorRequest(v2Search(payload, '&client=mobile&deeplinkScheme=https'), 'vault.bitwarden.eu');
const selfHosted = parseConnectorRequest(v2Search(payload, '&client=mobile&deeplinkScheme=https'), 'vault.example.com');
assert.equal(custom.callbackUri, 'bitwarden://webauthn-callback');
assert.equal(eu.callbackUri, 'https://bitwarden.eu/webauthn-callback');
assert.equal(selfHosted.callbackUri, 'https://bitwarden.com/webauthn-callback');
});
test('supports V1 mobile requests and requires a recognized mobile signal', () => {
const encoded = encodeURIComponent(encodeBase64Utf8(JSON.stringify(assertionOptions)));
assert.equal(parseConnectorRequest(`?data=${encoded}&v=1&client=mobile`).callbackUri, 'bitwarden://webauthn-callback');
assert.equal(resolveMobileCallbackUri({ payload: {}, hostname: 'vault.example.com' }), null);
assert.throws(() => parseConnectorRequest(`?data=${encoded}&v=1`), /return target/i);
});
test('decodes UTF-8 and normalizes WebAuthn binary fields without mutation', () => {
assert.equal(decodeBase64Utf8(encodeBase64Utf8('验证身份')), '验证身份');
const original = structuredClone(assertionOptions);
const normalized = normalizePublicKeyOptions(original);
assert.deepEqual(Array.from(normalized.challenge), [1, 2, 3, 250, 255]);
assert.deepEqual(Array.from(normalized.allowCredentials[0].id), [4, 5, 6, 7, 8]);
assert.deepEqual(original, assertionOptions);
});
test('serializes the exact assertion shape emitted by Bitwarden common-webauthn', () => {
const serialized = JSON.parse(buildCredentialData({
id: 'credential-id', rawId: Uint8Array.from([1, 2, 255]).buffer, type: 'public-key',
getClientExtensionResults: () => ({ appid: false }),
response: {
authenticatorData: Uint8Array.from([3, 4]).buffer,
clientDataJSON: Uint8Array.from([5, 6]).buffer,
signature: Uint8Array.from([7, 8]).buffer,
userHandle: Uint8Array.from([9, 10]).buffer,
},
}));
assert.deepEqual(serialized, {
id: 'credential-id',
rawId: 'AQL_',
type: 'public-key',
extensions: { appid: false },
response: { authenticatorData: 'AwQ', clientDataJson: 'BQY', signature: 'Bwg' },
});
assert.equal(base64UrlFromBuffer(Uint8Array.from([251, 255])), '-_8');
});
test('encodes success and error callbacks safely', () => {
assert.equal(buildCallbackUrl('bitwarden://webauthn-callback', 'data', '{"id":"a+b"}'), 'bitwarden://webauthn-callback?data=%7B%22id%22%3A%22a%2Bb%22%7D');
assert.equal(buildCallbackUrl('bitwarden://webauthn-callback?source=nodewarden', 'error', 'Not allowed'), 'bitwarden://webauthn-callback?source=nodewarden&error=Not%20allowed');
});
test('HTML matches the fallback connector visual structure', async () => {
const html = await readFile(new URL('../webapp/public/webauthn-mobile-connector.html', import.meta.url), 'utf8');
assert.match(html, /id="webauthn-header"/);
assert.match(html, /id="webauthn-button"/);
assert.match(html, /class="connector-card"/);
assert.match(html, /class="brand"/);
assert.match(html, /class="form"/);
assert.match(html, /class="msg"/);
assert.match(html, /src="\/nodewarden-logo\.svg"/);
assert.match(html, /src="\/webauthn-mobile-connector\.js"/);
assert.match(html, /default-src 'none'/);
});
test('runtime uses Bitwarden-compatible replacement navigation', async () => {
const source = await readFile(new URL('../webapp/public/webauthn-mobile-connector.js', import.meta.url), 'utf8');
assert.match(source, /window\.location\.replace\(uri\)/);
assert.doesNotMatch(source, /location\.assign/);
assert.doesNotMatch(source, /safeCallbackFromPayload/);
});
test('Service Worker keeps connector navigations out of the SPA shell', async () => {
const config = await readFile(new URL('../webapp/vite.config.ts', import.meta.url), 'utf8');
assert.match(config, /url\.pathname\.endsWith\('-connector\.html'\)/);
assert.match(config, /connectorNavigation\(request\)/);
assert.match(config, /WebAuthn connector is unavailable while offline/);
});
+52
View File
@@ -0,0 +1,52 @@
import { LIMITS } from './config/limits';
function buildIconServiceTemplate(origin: string): string {
return `${origin}/icons/{}/icon.png`;
}
function buildIconServiceCsp(origin: string): string {
return `img-src 'self' data: ${origin}`;
}
export function buildConfigResponse(origin: string) {
const fillAssistBase = `${origin}/fill-assist/`;
return {
version: LIMITS.compatibility.bitwardenServerVersion,
gitHash: 'nodewarden',
server: null,
environment: {
cloudRegion: 'self-hosted',
vault: origin,
api: origin + '/api',
identity: origin + '/identity',
notifications: origin + '/notifications',
icons: origin,
sso: '',
fillAssistRules: fillAssistBase,
},
push: {
pushTechnology: 0,
vapidPublicKey: null,
},
communication: null,
settings: {
disableUserRegistration: false,
},
_icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin),
featureStates: {
'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled,
'desktop-ui-settings-dialog': true,
'duo-redirect': true,
'email-verification': true,
'fill-assist-targeting-rules': true,
'pm-19051-send-email-verification': false,
'pm-19148-innovation-archive': true,
'pm-4516-devices-add-last-activity-date': true,
'pm-30529-webauthn-related-origins': true,
'unauth-ui-refresh': true,
'web-push': false,
},
object: 'config',
};
}
+1 -54
View File
@@ -36,6 +36,7 @@ import { jsonResponse, unsupportedResponse } from './utils/response';
import { StorageService } from './services/storage'; import { StorageService } from './services/storage';
import type { Env } from './types'; import type { Env } from './types';
import { getConfiguredWebAuthnAllowedOrigins } from './utils/origins'; import { getConfiguredWebAuthnAllowedOrigins } from './utils/origins';
import { buildConfigResponse } from './config-response';
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>; type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
type JwtUnsafeReason = 'missing' | 'too_short' | null; type JwtUnsafeReason = 'missing' | 'too_short' | null;
@@ -95,60 +96,6 @@ function handleMissingWebsiteIcon(): Response {
}); });
} }
function buildIconServiceBase(origin: string): string {
return `${origin}/icons`;
}
function buildIconServiceTemplate(origin: string): string {
return `${buildIconServiceBase(origin)}/{}/icon.png`;
}
function buildIconServiceCsp(origin: string): string {
return `img-src 'self' data: ${origin}`;
}
function buildConfigResponse(origin: string) {
const fillAssistBase = `${origin}/fill-assist/`;
return {
version: LIMITS.compatibility.bitwardenServerVersion,
gitHash: 'nodewarden',
server: null,
environment: {
cloudRegion: 'self-hosted',
vault: origin,
api: origin + '/api',
identity: origin + '/identity',
notifications: origin + '/notifications',
icons: origin,
sso: '',
fillAssistRules: fillAssistBase,
},
push: {
pushTechnology: 0,
vapidPublicKey: null,
},
communication: null,
settings: {
disableUserRegistration: false,
},
_icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin),
featureStates: {
'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled,
'duo-redirect': true,
'email-verification': true,
'fill-assist-targeting-rules': true,
'pm-19051-send-email-verification': false,
'pm-19148-innovation-archive': true,
'pm-4516-devices-add-last-activity-date': true,
'pm-30529-webauthn-related-origins': true,
'unauth-ui-refresh': true,
'web-push': false,
},
object: 'config',
};
}
function normalizeIconHost(rawHost: string): string | null { function normalizeIconHost(rawHost: string): string | null {
let decoded: string; let decoded: string;
try { try {
+18 -1
View File
@@ -8,6 +8,13 @@ export const OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS = [
'chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn', 'chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn',
] as const; ] as const;
// Bitwarden desktop is migrating from file:// to this privileged Electron
// origin. Official clients keep the legacy file:// path as a compatibility
// fallback while self-hosted servers add CORS support for the new origin.
export const OFFICIAL_BITWARDEN_DESKTOP_ORIGINS = [
'bw-desktop-file://bundle',
] as const;
export function normalizeOrigin(value: unknown): string | null { export function normalizeOrigin(value: unknown): string | null {
const raw = String(value || '').trim(); const raw = String(value || '').trim();
if (!raw) return null; if (!raw) return null;
@@ -30,10 +37,20 @@ export function isBrowserExtensionOrigin(origin: unknown): boolean {
); );
} }
export function isOfficialBitwardenDesktopOrigin(origin: unknown): boolean {
const normalized = normalizeOrigin(origin);
return !!normalized && OFFICIAL_BITWARDEN_DESKTOP_ORIGINS.includes(
normalized as (typeof OFFICIAL_BITWARDEN_DESKTOP_ORIGINS)[number]
);
}
export function getConfiguredWebAuthnAllowedOrigins( export function getConfiguredWebAuthnAllowedOrigins(
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'> env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>
): string[] { ): string[] {
const seen = new Set<string>(OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS); const seen = new Set<string>([
...OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS,
...OFFICIAL_BITWARDEN_DESKTOP_ORIGINS,
]);
for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) { for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) {
const origin = normalizeOrigin(item); const origin = normalizeOrigin(item);
if (origin) seen.add(origin); if (origin) seen.add(origin);
+19 -3
View File
@@ -3,6 +3,7 @@ import type { Env } from '../types';
import { import {
isBrowserExtensionOrigin, isBrowserExtensionOrigin,
isConfiguredWebAuthnAllowedOrigin, isConfiguredWebAuthnAllowedOrigin,
isOfficialBitwardenDesktopOrigin,
normalizeOrigin, normalizeOrigin,
} from './origins'; } from './origins';
@@ -48,7 +49,10 @@ function getCorsPolicy(request: Request, env: Env): { allowOrigin: string | null
if (origin === url.origin) { if (origin === url.origin) {
return { allowOrigin: origin, allowCredentials: true }; return { allowOrigin: origin, allowCredentials: true };
} }
if (isBrowserExtensionOrigin(origin) && isConfiguredWebAuthnAllowedOrigin(env, origin)) { if (
(isBrowserExtensionOrigin(origin) || isOfficialBitwardenDesktopOrigin(origin))
&& isConfiguredWebAuthnAllowedOrigin(env, origin)
) {
return { allowOrigin: origin, allowCredentials: true }; return { allowOrigin: origin, allowCredentials: true };
} }
if (isWildcardCorsPath(url.pathname)) { if (isWildcardCorsPath(url.pathname)) {
@@ -100,10 +104,22 @@ export function applyCors(
headers.set(k, v); headers.set(k, v);
} }
// Security headers applied to every response. // Security headers applied to every response.
headers.set('X-Frame-Options', 'DENY');
headers.set('X-Content-Type-Options', 'nosniff'); headers.set('X-Content-Type-Options', 'nosniff');
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
if (!headers.has('Content-Security-Policy')) { const isWebAuthnFrameConnector = new URL(request.url).pathname === '/webauthn-connector.html';
if (isWebAuthnFrameConnector) {
// Official desktop and browser clients render this exact endpoint inside a
// 40px cross-origin iframe. The connector validates its parent before any
// WebAuthn request or postMessage, so only this protocol page may be framed.
headers.delete('X-Frame-Options');
headers.set(
'Content-Security-Policy',
"default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'"
);
} else {
headers.set('X-Frame-Options', 'DENY');
}
if (!isWebAuthnFrameConnector && !headers.has('Content-Security-Policy')) {
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:"); headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:");
} }
return new Response(response.body, { return new Response(response.body, {
+74
View File
@@ -0,0 +1,74 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'"
/>
<title>NodeWarden WebAuthn Connector</title>
<style>
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: transparent;
}
body {
display: flex;
align-items: center;
justify-content: center;
}
button {
width: 100%;
min-height: 40px;
padding: 8px 14px;
border: 1px solid #2563eb;
border-radius: 10px;
background: #2563eb;
color: #fff;
cursor: pointer;
font: inherit;
font-weight: 800;
line-height: 1.2;
transition: background-color 160ms ease, border-color 160ms ease;
}
button:hover {
border-color: #1d4ed8;
background: #1d4ed8;
}
button:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
button[aria-disabled="true"] {
border-color: #d0d5dd;
background: #d0d5dd;
color: #667085;
cursor: not-allowed;
}
</style>
</head>
<body>
<button id="webauthn-button" type="button" aria-live="polite">Read security key</button>
<script type="module" src="/webauthn-connector.js"></script>
</body>
</html>
+268
View File
@@ -0,0 +1,268 @@
const OFFICIAL_DESKTOP_ORIGIN = 'bw-desktop-file://bundle';
function safeDecodeURIComponent(value) {
let decoded = String(value || '');
for (let index = 0; index < 2 && /%[0-9a-f]{2}/i.test(decoded); index += 1) {
try {
const next = decodeURIComponent(decoded);
if (next === decoded) break;
decoded = next;
} catch (_error) {
break;
}
}
return decoded;
}
export function decodeBase64Utf8(value) {
let normalized = String(value || '').replace(/ /g, '+').replace(/-/g, '+').replace(/_/g, '/');
normalized += '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
let binary;
try {
binary = atob(normalized);
} catch (_error) {
throw new Error('Cannot parse WebAuthn data.');
}
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
if (typeof TextDecoder !== 'undefined') return new TextDecoder().decode(bytes);
return decodeURIComponent(Array.from(bytes, (byte) => `%${byte.toString(16).padStart(2, '0')}`).join(''));
}
export function bytesFromBase64Url(value) {
let normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
normalized += '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
try {
return Uint8Array.from(atob(normalized), (character) => character.charCodeAt(0));
} catch (_error) {
throw new Error('Cannot parse WebAuthn data.');
}
}
export function base64UrlFromBuffer(value) {
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
let binary = '';
for (let index = 0; index < bytes.length; index += 1) binary += String.fromCharCode(bytes[index]);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
export function parseConnectorRequest(search) {
const params = search instanceof URLSearchParams
? search
: new URLSearchParams(String(search || '').replace(/^\?/, ''));
const parentUrl = safeDecodeURIComponent(params.get('parent'));
const encodedData = params.get('data');
if (!parentUrl) throw new Error('No parent.');
if (!encodedData) throw new Error('No data.');
let parsedParent;
try {
parsedParent = new URL(parentUrl);
} catch (_error) {
throw new Error('Invalid parent.');
}
let webauthnJson;
if (params.get('v') === '1') {
webauthnJson = decodeBase64Utf8(encodedData);
} else {
let payload;
try {
payload = JSON.parse(decodeBase64Utf8(encodedData));
} catch (_error) {
throw new Error('Cannot parse data.');
}
if (!payload || (typeof payload.data !== 'string' && typeof payload.data !== 'object')) {
throw new Error('Cannot parse data.');
}
webauthnJson = typeof payload.data === 'string' ? payload.data : JSON.stringify(payload.data);
}
return {
parentUrl,
parentProtocol: parsedParent.protocol.toLowerCase(),
parentOrigin: parsedParent.origin,
webauthnJson,
buttonText: safeDecodeURIComponent(params.get('btnText')),
awaitingText: safeDecodeURIComponent(params.get('btnAwaitingInteractionText')),
};
}
export function normalizePublicKeyOptions(webauthnJson) {
const source = typeof webauthnJson === 'string' ? JSON.parse(webauthnJson) : webauthnJson;
if (!source || typeof source !== 'object' || !source.challenge) throw new Error('Cannot parse WebAuthn data.');
const publicKey = { ...source, challenge: bytesFromBase64Url(source.challenge) };
if (Array.isArray(source.allowCredentials)) {
publicKey.allowCredentials = source.allowCredentials.map((credential) => ({
...credential,
id: bytesFromBase64Url(credential?.id),
}));
}
return publicKey;
}
export function buildCredentialData(assertedCredential) {
const response = assertedCredential?.response;
if (!assertedCredential || !response?.authenticatorData || !response?.clientDataJSON || !response?.signature) {
throw new Error('The authenticator returned an incomplete response.');
}
return JSON.stringify({
id: assertedCredential.id,
rawId: base64UrlFromBuffer(assertedCredential.rawId),
type: assertedCredential.type,
extensions: typeof assertedCredential.getClientExtensionResults === 'function'
? assertedCredential.getClientExtensionResults()
: {},
response: {
authenticatorData: base64UrlFromBuffer(response.authenticatorData),
clientDataJson: base64UrlFromBuffer(response.clientDataJSON),
signature: base64UrlFromBuffer(response.signature),
},
});
}
function normalizeAllowedOrigin(value) {
try {
const url = new URL(String(value || ''));
return url.protocol && url.host ? `${url.protocol}//${url.host}` : '';
} catch (_error) {
return '';
}
}
function isExtensionOrigin(origin) {
return origin.startsWith('chrome-extension://')
|| origin.startsWith('moz-extension://')
|| origin.startsWith('safari-web-extension://');
}
export function resolveParentChannel(request, connectorOrigin, allowedOrigins = []) {
if (request.parentProtocol === 'file:') {
return { eventOrigin: 'null', targetOrigin: request.parentUrl };
}
const parentOrigin = normalizeAllowedOrigin(request.parentUrl);
if (!parentOrigin) throw new Error('Invalid parent.');
if (parentOrigin === connectorOrigin) {
return { eventOrigin: parentOrigin, targetOrigin: parentOrigin };
}
if (parentOrigin === OFFICIAL_DESKTOP_ORIGIN) {
return { eventOrigin: parentOrigin, targetOrigin: request.parentUrl };
}
const trustedOrigins = allowedOrigins.map(normalizeAllowedOrigin).filter(Boolean);
if (isExtensionOrigin(parentOrigin) && trustedOrigins.includes(parentOrigin)) {
return { eventOrigin: parentOrigin, targetOrigin: parentOrigin };
}
throw new Error('Untrusted parent.');
}
async function loadAllowedParentOrigins() {
try {
const response = await fetch('/api/web-bootstrap', {
headers: { Accept: 'application/json' },
credentials: 'omit',
});
if (!response.ok) return [];
const body = await response.json();
return Array.isArray(body?.webAuthnAllowedOrigins) ? body.webAuthnAllowedOrigins : [];
} catch (_error) {
return [];
}
}
function browserErrorMessage(error) {
return error?.message || String(error || 'WebAuthn failed.');
}
async function initializePage() {
const button = document.getElementById('webauthn-button');
if (!button) return;
let request;
let publicKey;
let channel;
let stopWebAuthn = false;
let sentSuccess = false;
let running = false;
const defaultText = 'Read security key';
const awaitingDefaultText = 'Awaiting security key interaction...';
function setButton(awaiting = false) {
button.textContent = awaiting
? request?.awaitingText || awaitingDefaultText
: request?.buttonText || defaultText;
button.setAttribute('aria-disabled', awaiting ? 'true' : 'false');
button.setAttribute('aria-busy', awaiting ? 'true' : 'false');
button.onclick = awaiting ? null : executeWebAuthn;
}
function post(message) {
window.parent.postMessage(message, channel.targetOrigin);
}
function reportError(error) {
if (channel) post(`error|${browserErrorMessage(error)}`);
setButton(false);
}
async function executeWebAuthn() {
if (running || sentSuccess) return;
if (stopWebAuthn) {
stopWebAuthn = false;
setButton(false);
return;
}
running = true;
setButton(true);
try {
const credential = await navigator.credentials.get({ publicKey });
if (!credential) throw new Error('No security key was selected.');
if (sentSuccess) return;
post(`success|${buildCredentialData(credential)}`);
sentSuccess = true;
} catch (error) {
reportError(error);
} finally {
running = false;
}
}
try {
request = parseConnectorRequest(window.location.search);
publicKey = normalizePublicKeyOptions(request.webauthnJson);
channel = resolveParentChannel(request, window.location.origin, await loadAllowedParentOrigins());
setButton(false);
} catch (error) {
button.textContent = browserErrorMessage(error);
button.setAttribute('aria-disabled', 'true');
return;
}
if (!navigator.credentials || typeof navigator.credentials.get !== 'function' || !window.PublicKeyCredential) {
reportError(new Error('WebAuthn is not supported in this browser.'));
return;
}
window.addEventListener('message', (event) => {
if (event.source !== window.parent || event.origin !== channel.eventOrigin) return;
if (event.data === 'stop') {
stopWebAuthn = true;
setButton(false);
} else if (event.data === 'start' && stopWebAuthn) {
stopWebAuthn = false;
void executeWebAuthn();
}
});
post('info|ready');
const isSafari = navigator.userAgent.includes(' Safari/') && !navigator.userAgent.includes('Chrome');
if (!isSafari) void executeWebAuthn();
}
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => void initializePage(), { once: true });
} else {
void initializePage();
}
}
@@ -0,0 +1,174 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#f6f8fb" />
<meta name="color-scheme" content="light" />
<meta name="referrer" content="no-referrer" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self'; connect-src 'none'; base-uri 'none'; form-action 'none'"
/>
<title>NodeWarden WebAuthn Connector</title>
<style>
:root {
color-scheme: light;
--primary: #2563eb;
--primary-strong: #1d4ed8;
--text: #101828;
--muted: #667085;
--line: #d8e0ec;
--panel: #ffffff;
--surface: #f6f8fb;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
background: var(--surface);
color: var(--text);
}
main {
display: grid;
min-height: 100vh;
min-height: 100svh;
place-items: center;
padding: max(28px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right)) max(28px, env(safe-area-inset-bottom)) max(18px, env(safe-area-inset-left));
}
.connector-card {
width: min(100%, 430px);
border: 1px solid var(--line);
border-radius: 18px;
background: var(--panel);
box-shadow: 0 18px 44px rgba(16, 24, 40, 0.10);
padding: 28px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 28px;
}
.brand img {
width: 44px;
height: 44px;
object-fit: contain;
}
.brand strong {
font-size: 18px;
line-height: 1;
}
h1 {
margin: 0 0 8px;
font-size: 26px;
line-height: 1.2;
}
p {
margin: 0;
color: var(--muted);
line-height: 1.55;
}
.form {
display: grid;
gap: 16px;
margin-top: 24px;
}
button {
min-height: 48px;
width: 100%;
border: 1px solid var(--primary);
border-radius: 10px;
background: var(--primary);
color: #fff;
cursor: pointer;
font: inherit;
font-weight: 800;
transition: background-color 160ms ease, border-color 160ms ease, transform 120ms ease;
}
button:hover:not(:disabled)[aria-disabled="false"] {
background: var(--primary-strong);
border-color: var(--primary-strong);
}
button:active:not(:disabled)[aria-disabled="false"] {
transform: translateY(1px);
}
button:disabled,
button[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.62;
}
button[data-state="return"] {
cursor: pointer;
opacity: 1;
}
.msg {
display: none;
border-radius: 10px;
padding: 11px 12px;
font-size: 14px;
line-height: 1.45;
}
.msg.show {
display: block;
}
.msg.info {
border: 1px solid #bfdbfe;
background: #eff6ff;
color: #1e40af;
}
.msg.error {
border: 1px solid #fecaca;
background: #fef2f2;
color: #991b1b;
}
.msg.success {
border: 1px solid #bbf7d0;
background: #f0fdf4;
color: #166534;
}
</style>
</head>
<body>
<main>
<section class="connector-card" aria-labelledby="webauthn-header">
<div class="brand">
<img src="/nodewarden-logo.svg" alt="NodeWarden" />
<strong>NodeWarden</strong>
</div>
<h1 id="webauthn-header">Verify your identity</h1>
<p id="webauthn-copy">Use your security key to finish two-step verification.</p>
<div class="form">
<div id="webauthn-status" class="msg" role="status" aria-live="polite" hidden></div>
<button id="webauthn-button" type="button" data-state="loading" aria-busy="true" aria-disabled="true">
Preparing passkey…
</button>
</div>
</section>
</main>
<script type="module" src="/webauthn-mobile-connector.js"></script>
</body>
</html>
+272
View File
@@ -0,0 +1,272 @@
const CUSTOM_SCHEME_CALLBACK = 'bitwarden://webauthn-callback';
const APP_LINK_HOSTS = ['bitwarden.com', 'bitwarden.eu', 'bitwarden.pw', 'bitwarden-gov.com'];
function safeDecodeURIComponent(value) {
let decoded = String(value || '');
for (let index = 0; index < 2 && /%[0-9a-f]{2}/i.test(decoded); index += 1) {
try {
decoded = decodeURIComponent(decoded);
} catch (_error) {
break;
}
}
return decoded;
}
export function decodeBase64Utf8(value) {
let normalized = String(value || '').replace(/ /g, '+').replace(/-/g, '+').replace(/_/g, '/');
normalized += '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
let binary;
try {
binary = atob(normalized);
} catch (_error) {
throw new Error('The WebAuthn challenge is not valid Base64.');
}
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
if (typeof TextDecoder !== 'undefined') return new TextDecoder().decode(bytes);
return decodeURIComponent(Array.from(bytes, (byte) => `%${byte.toString(16).padStart(2, '0')}`).join(''));
}
export function bytesFromBase64Url(value) {
let normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
normalized += '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
try {
return Uint8Array.from(atob(normalized), (character) => character.charCodeAt(0));
} catch (_error) {
throw new Error('The WebAuthn challenge contains invalid binary data.');
}
}
export function base64UrlFromBuffer(value) {
if (value == null) return undefined;
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
let binary = '';
for (let index = 0; index < bytes.length; index += 1) binary += String.fromCharCode(bytes[index]);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function officialAppLinkHost(hostname) {
const normalized = String(hostname || '').toLowerCase();
return APP_LINK_HOSTS.find((host) => normalized === host || normalized.endsWith(`.${host}`)) || 'bitwarden.com';
}
export function resolveMobileCallbackUri({ deeplinkScheme, payload, hostname, legacyMobile = false }) {
// Match Bitwarden's connector protocol: the scheme parameter governs the
// callback shape. Any non-HTTPS scheme resolves to Bitwarden's fixed custom
// scheme; a client-provided callbackUri is only a mobile-flow signal.
if (deeplinkScheme) {
return String(deeplinkScheme).toLowerCase() === 'https'
? `https://${officialAppLinkHost(hostname)}/webauthn-callback`
: CUSTOM_SCHEME_CALLBACK;
}
return payload?.mobile === true || payload?.callbackUri != null || legacyMobile
? CUSTOM_SCHEME_CALLBACK
: null;
}
export function parseConnectorRequest(search, hostname = '') {
const params = search instanceof URLSearchParams
? search
: new URLSearchParams(String(search || '').replace(/^\?/, ''));
const encodedData = params.get('data');
if (!encodedData) throw new Error('No WebAuthn challenge was provided.');
const version = params.get('v');
let payload = null;
let webauthnJson;
let headerText;
let buttonText;
let returnButtonText;
let awaitingText;
if (version === '1') {
webauthnJson = decodeBase64Utf8(encodedData);
headerText = params.get('headerText');
buttonText = params.get('btnText');
returnButtonText = params.get('btnReturnText');
awaitingText = params.get('btnAwaitingInteractionText');
} else {
try {
payload = JSON.parse(decodeBase64Utf8(encodedData));
} catch (_error) {
throw new Error('The WebAuthn challenge could not be decoded.');
}
if (!payload || (typeof payload.data !== 'string' && typeof payload.data !== 'object')) {
throw new Error('The WebAuthn challenge is incomplete.');
}
webauthnJson = typeof payload.data === 'string' ? payload.data : JSON.stringify(payload.data);
headerText = payload.headerText;
buttonText = payload.btnText;
returnButtonText = payload.btnReturnText;
awaitingText = payload.btnAwaitingInteractionText;
}
const callbackUri = resolveMobileCallbackUri({
deeplinkScheme: params.get('deeplinkScheme'),
payload,
hostname,
legacyMobile: params.get('client') === 'mobile',
});
if (!callbackUri) throw new Error('No supported mobile return target was provided.');
return {
callbackUri,
webauthnJson,
headerText: safeDecodeURIComponent(headerText),
buttonText: safeDecodeURIComponent(buttonText),
returnButtonText: safeDecodeURIComponent(returnButtonText),
awaitingText: safeDecodeURIComponent(awaitingText),
};
}
export function normalizePublicKeyOptions(webauthnJson) {
const source = typeof webauthnJson === 'string' ? JSON.parse(webauthnJson) : webauthnJson;
if (!source || typeof source !== 'object' || !source.challenge) {
throw new Error('The WebAuthn challenge is invalid.');
}
const publicKey = { ...source, challenge: bytesFromBase64Url(source.challenge) };
if (Array.isArray(source.allowCredentials)) {
publicKey.allowCredentials = source.allowCredentials.map((credential) => ({
...credential,
id: bytesFromBase64Url(credential?.id),
}));
}
return publicKey;
}
export function buildCredentialData(assertedCredential) {
const response = assertedCredential?.response;
if (!assertedCredential || !response?.authenticatorData || !response?.clientDataJSON || !response?.signature) {
throw new Error('The authenticator returned an incomplete response.');
}
const extensions = typeof assertedCredential.getClientExtensionResults === 'function'
? assertedCredential.getClientExtensionResults()
: {};
const clientData = base64UrlFromBuffer(response.clientDataJSON);
return JSON.stringify({
id: assertedCredential.id,
rawId: base64UrlFromBuffer(assertedCredential.rawId),
type: assertedCredential.type,
extensions,
response: {
authenticatorData: base64UrlFromBuffer(response.authenticatorData),
clientDataJson: clientData,
signature: base64UrlFromBuffer(response.signature),
},
});
}
export function buildCallbackUrl(callbackUri, key, value) {
const separator = String(callbackUri).includes('?') ? '&' : '?';
return `${callbackUri}${separator}${encodeURIComponent(key)}=${encodeURIComponent(String(value || ''))}`;
}
function translations(locale) {
const normalized = String(locale || 'en').toLowerCase();
if (normalized.startsWith('zh-tw') || normalized.startsWith('zh-hk')) {
return {
title: '兩步驟驗證', copy: '使用通行密鑰或安全金鑰完成登入。', button: '使用通行密鑰驗證',
awaiting: '請依照系統提示完成驗證…', returning: '正在返回 Bitwarden…', returnButton: '返回 Bitwarden',
unsupported: '此瀏覽器不支援通行密鑰。', cancelled: '驗證已取消,請重試。',
};
}
if (normalized.startsWith('zh')) {
return {
title: '两步验证', copy: '使用通行密钥或安全密钥完成登录。', button: '使用通行密钥验证',
awaiting: '请按照系统提示完成验证…', returning: '正在返回 Bitwarden…', returnButton: '返回 Bitwarden',
unsupported: '此浏览器不支持通行密钥。', cancelled: '验证已取消,请重试。',
};
}
return {
title: 'Two-step verification', copy: 'Use your passkey or security key to finish signing in.',
button: 'Authenticate with passkey', awaiting: 'Follow the system prompt to continue…',
returning: 'Returning to Bitwarden…', returnButton: 'Return to Bitwarden',
unsupported: 'This browser does not support passkeys.', cancelled: 'Verification was cancelled. Please try again.',
};
}
function browserErrorMessage(error, text) {
if (error?.name === 'NotAllowedError' || error?.name === 'AbortError') return text.cancelled;
return error?.message || String(error || 'WebAuthn failed.');
}
function initializePage() {
const button = document.getElementById('webauthn-button');
const header = document.getElementById('webauthn-header');
const copy = document.getElementById('webauthn-copy');
const status = document.getElementById('webauthn-status');
if (!button || !header || !copy || !status) return;
const text = translations(navigator.languages?.[0] || navigator.language);
document.documentElement.lang = navigator.languages?.[0] || navigator.language || 'en';
copy.textContent = text.copy;
let request;
let publicKey;
let completed = false;
let returnUri = '';
function setButton(label, state, handler) {
button.textContent = label;
button.dataset.state = state;
button.disabled = state === 'unavailable';
button.setAttribute('aria-disabled', handler ? 'false' : 'true');
button.setAttribute('aria-busy', state === 'waiting' ? 'true' : 'false');
button.onclick = handler;
}
function setStatus(kind, message) {
status.hidden = !message;
status.dataset.kind = kind;
status.textContent = message || '';
status.className = message ? `msg show ${kind}` : 'msg';
}
function navigate(uri) {
returnUri = uri;
window.location.replace(uri);
setButton(request?.returnButtonText || text.returnButton, 'return', () => window.location.replace(returnUri));
}
function handoffError(message) {
setStatus('error', message);
if (request?.callbackUri) navigate(buildCallbackUrl(request.callbackUri, 'error', message));
}
async function executeWebAuthn() {
if (completed || button.dataset.state === 'waiting') return;
setStatus('info', request.awaitingText || text.awaiting);
setButton(request.awaitingText || text.awaiting, 'waiting', null);
try {
const credential = await navigator.credentials.get({ publicKey });
if (!credential) throw new Error('No passkey was selected.');
const data = buildCredentialData(credential);
completed = true;
setStatus('success', text.returning);
navigate(buildCallbackUrl(request.callbackUri, 'data', data));
} catch (error) {
setButton(request.buttonText || text.button, 'ready', executeWebAuthn);
handoffError(browserErrorMessage(error, text));
}
}
try {
request = parseConnectorRequest(window.location.search, window.location.hostname);
publicKey = normalizePublicKeyOptions(request.webauthnJson);
header.textContent = request.headerText || text.title;
setButton(request.buttonText || text.button, 'ready', executeWebAuthn);
} catch (error) {
header.textContent = text.title;
setStatus('error', browserErrorMessage(error, text));
setButton(text.button, 'unavailable', null);
}
if (!navigator.credentials || typeof navigator.credentials.get !== 'function' || !window.PublicKeyCredential) {
handoffError(text.unsupported);
if (!request?.callbackUri) setButton(text.button, 'unavailable', null);
}
}
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initializePage, { once: true });
else initializePage();
}
+17 -5
View File
@@ -1,4 +1,12 @@
import { bytesToBase64, decryptBw, encryptBw, hkdfExpand, pbkdf2 } from '../crypto'; import {
bytesToBase64,
decryptBw,
encryptBw,
hkdfExpand,
pbkdf2,
requireWebCrypto,
WebCryptoUnavailableError,
} from '../crypto';
import { t, translateServerError } from '../i18n'; import { t, translateServerError } from '../i18n';
import type { AuthorizedDevice } from '../types'; import type { AuthorizedDevice } from '../types';
import type { import type {
@@ -428,14 +436,15 @@ export async function registerAccount(args: {
}): Promise<{ ok: true } | { ok: false; message: string }> { }): Promise<{ ok: true } | { ok: false; message: string }> {
try { try {
const { email, name, password, masterPasswordHint, inviteCode, fallbackIterations } = args; const { email, name, password, masterPasswordHint, inviteCode, fallbackIterations } = args;
const webCrypto = requireWebCrypto();
const masterKey = await pbkdf2(password, email, fallbackIterations, 32); const masterKey = await pbkdf2(password, email, fallbackIterations, 32);
const masterHash = await pbkdf2(masterKey, password, 1, 32); const masterHash = await pbkdf2(masterKey, password, 1, 32);
const encKey = await hkdfExpand(masterKey, 'enc', 32); const encKey = await hkdfExpand(masterKey, 'enc', 32);
const macKey = await hkdfExpand(masterKey, 'mac', 32); const macKey = await hkdfExpand(masterKey, 'mac', 32);
const sym = crypto.getRandomValues(new Uint8Array(64)); const sym = webCrypto.getRandomValues(new Uint8Array(64));
const encryptedVaultKey = await encryptBw(sym, encKey, macKey); const encryptedVaultKey = await encryptBw(sym, encKey, macKey);
const keyPair = await crypto.subtle.generateKey( const keyPair = await webCrypto.subtle.generateKey(
{ {
name: 'RSA-OAEP', name: 'RSA-OAEP',
modulusLength: 2048, modulusLength: 2048,
@@ -445,8 +454,8 @@ export async function registerAccount(args: {
true, true,
['encrypt', 'decrypt'] ['encrypt', 'decrypt']
); );
const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey)); const publicKey = new Uint8Array(await webCrypto.subtle.exportKey('spki', keyPair.publicKey));
const privateKey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey)); const privateKey = new Uint8Array(await webCrypto.subtle.exportKey('pkcs8', keyPair.privateKey));
const encryptedPrivateKey = await encryptBw(privateKey, sym.slice(0, 32), sym.slice(32, 64)); const encryptedPrivateKey = await encryptBw(privateKey, sym.slice(0, 32), sym.slice(32, 64));
const resp = await fetch('/api/accounts/register', { const resp = await fetch('/api/accounts/register', {
@@ -474,6 +483,9 @@ export async function registerAccount(args: {
} }
return { ok: true }; return { ok: true };
} catch (error) { } catch (error) {
if (error instanceof WebCryptoUnavailableError) {
return { ok: false, message: t('txt_web_crypto_unavailable') };
}
return { ok: false, message: error instanceof Error ? translateServerError(error.message, error.message) : t('txt_register_failed') }; return { ok: false, message: error instanceof Error ? translateServerError(error.message, error.message) : t('txt_register_failed') };
} }
} }
+52 -17
View File
@@ -1,3 +1,34 @@
export const WEB_CRYPTO_UNAVAILABLE_MESSAGE =
'Secure browser cryptography is unavailable. Open NodeWarden over HTTPS in a supported browser.';
export class WebCryptoUnavailableError extends Error {
constructor() {
super(WEB_CRYPTO_UNAVAILABLE_MESSAGE);
this.name = 'WebCryptoUnavailableError';
}
}
interface WebCryptoEnvironment {
crypto?: Crypto;
isSecureContext?: boolean;
}
export function requireWebCrypto(
environment: WebCryptoEnvironment = globalThis as unknown as WebCryptoEnvironment
): Crypto {
const cryptoApi = environment.crypto;
if (
environment.isSecureContext === false ||
!cryptoApi ||
typeof cryptoApi.getRandomValues !== 'function' ||
!cryptoApi.subtle ||
typeof cryptoApi.subtle.importKey !== 'function'
) {
throw new WebCryptoUnavailableError();
}
return cryptoApi;
}
export function bytesToBase64(bytes: Uint8Array): string { export function bytesToBase64(bytes: Uint8Array): string {
let s = ''; let s = '';
for (let i = 0; i < bytes.length; i += 1) s += String.fromCharCode(bytes[i]); for (let i = 0; i < bytes.length; i += 1) s += String.fromCharCode(bytes[i]);
@@ -24,7 +55,7 @@ export function toBufferSource(bytes: Uint8Array): ArrayBuffer {
export async function sha256Base64(value: string): Promise<string> { export async function sha256Base64(value: string): Promise<string> {
const bytes = new TextEncoder().encode(value); const bytes = new TextEncoder().encode(value);
const hash = await crypto.subtle.digest('SHA-256', toBufferSource(bytes)); const hash = await requireWebCrypto().subtle.digest('SHA-256', toBufferSource(bytes));
return bytesToBase64(new Uint8Array(hash)); return bytesToBase64(new Uint8Array(hash));
} }
@@ -51,7 +82,7 @@ function getHmacSha256Key(keyBytes: Uint8Array): Promise<CryptoKey> {
return getCachedCryptoKey( return getCachedCryptoKey(
hmacSha256KeyCache, hmacSha256KeyCache,
keyBytes, keyBytes,
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) () => requireWebCrypto().subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
); );
} }
@@ -59,7 +90,7 @@ function getAesCbcEncryptKey(keyBytes: Uint8Array): Promise<CryptoKey> {
return getCachedCryptoKey( return getCachedCryptoKey(
aesCbcEncryptKeyCache, aesCbcEncryptKeyCache,
keyBytes, keyBytes,
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['encrypt']) () => requireWebCrypto().subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['encrypt'])
); );
} }
@@ -67,7 +98,7 @@ function getAesCbcDecryptKey(keyBytes: Uint8Array): Promise<CryptoKey> {
return getCachedCryptoKey( return getCachedCryptoKey(
aesCbcDecryptKeyCache, aesCbcDecryptKeyCache,
keyBytes, keyBytes,
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['decrypt']) () => requireWebCrypto().subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['decrypt'])
); );
} }
@@ -88,8 +119,9 @@ export async function pbkdf2(
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const pwdBytes = typeof passwordOrBytes === 'string' ? new TextEncoder().encode(passwordOrBytes) : passwordOrBytes; const pwdBytes = typeof passwordOrBytes === 'string' ? new TextEncoder().encode(passwordOrBytes) : passwordOrBytes;
const saltBytes = typeof saltOrBytes === 'string' ? new TextEncoder().encode(saltOrBytes) : saltOrBytes; const saltBytes = typeof saltOrBytes === 'string' ? new TextEncoder().encode(saltOrBytes) : saltOrBytes;
const key = await crypto.subtle.importKey('raw', toBufferSource(pwdBytes), 'PBKDF2', false, ['deriveBits']); const subtle = requireWebCrypto().subtle;
const bits = await crypto.subtle.deriveBits( const key = await subtle.importKey('raw', toBufferSource(pwdBytes), 'PBKDF2', false, ['deriveBits']);
const bits = await subtle.deriveBits(
{ name: 'PBKDF2', hash: 'SHA-256', salt: toBufferSource(saltBytes), iterations }, { name: 'PBKDF2', hash: 'SHA-256', salt: toBufferSource(saltBytes), iterations },
key, key,
keyLen * 8 keyLen * 8
@@ -99,7 +131,8 @@ export async function pbkdf2(
export async function hkdfExpand(prk: Uint8Array, info: string, length: number): Promise<Uint8Array> { export async function hkdfExpand(prk: Uint8Array, info: string, length: number): Promise<Uint8Array> {
const infoBytes = new TextEncoder().encode(info || ''); const infoBytes = new TextEncoder().encode(info || '');
const key = await crypto.subtle.importKey('raw', toBufferSource(prk), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); const subtle = requireWebCrypto().subtle;
const key = await subtle.importKey('raw', toBufferSource(prk), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const result = new Uint8Array(length); const result = new Uint8Array(length);
let previous = new Uint8Array(0); let previous = new Uint8Array(0);
let offset = 0; let offset = 0;
@@ -110,7 +143,7 @@ export async function hkdfExpand(prk: Uint8Array, info: string, length: number):
input.set(previous, 0); input.set(previous, 0);
input.set(infoBytes, previous.length); input.set(infoBytes, previous.length);
input[input.length - 1] = counter & 0xff; input[input.length - 1] = counter & 0xff;
previous = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(input))); previous = new Uint8Array(await subtle.sign('HMAC', key, toBufferSource(input)));
const copyLen = Math.min(previous.length, length - offset); const copyLen = Math.min(previous.length, length - offset);
result.set(previous.slice(0, copyLen), offset); result.set(previous.slice(0, copyLen), offset);
offset += copyLen; offset += copyLen;
@@ -134,28 +167,29 @@ export async function hkdf(
info: toBufferSource(infoBytes), info: toBufferSource(infoBytes),
hash: 'SHA-256', hash: 'SHA-256',
}; };
const key = await crypto.subtle.importKey('raw', toBufferSource(ikm), 'HKDF', false, ['deriveBits']); const subtle = requireWebCrypto().subtle;
const bits = await crypto.subtle.deriveBits(params, key, outputByteSize * 8); const key = await subtle.importKey('raw', toBufferSource(ikm), 'HKDF', false, ['deriveBits']);
const bits = await subtle.deriveBits(params, key, outputByteSize * 8);
return new Uint8Array(bits); return new Uint8Array(bits);
} }
async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise<Uint8Array> { async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise<Uint8Array> {
const key = await getHmacSha256Key(keyBytes); const key = await getHmacSha256Key(keyBytes);
return new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(dataBytes))); return new Uint8Array(await requireWebCrypto().subtle.sign('HMAC', key, toBufferSource(dataBytes)));
} }
async function encryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> { async function encryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
const cryptoKey = await getAesCbcEncryptKey(key); const cryptoKey = await getAesCbcEncryptKey(key);
return new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data))); return new Uint8Array(await requireWebCrypto().subtle.encrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
} }
async function decryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> { async function decryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
const cryptoKey = await getAesCbcDecryptKey(key); const cryptoKey = await getAesCbcDecryptKey(key);
return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data))); return new Uint8Array(await requireWebCrypto().subtle.decrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
} }
export async function encryptBwFileData(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<Uint8Array> { export async function encryptBwFileData(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<Uint8Array> {
const iv = crypto.getRandomValues(new Uint8Array(16)); const iv = requireWebCrypto().getRandomValues(new Uint8Array(16));
const cipher = await encryptAesCbc(data, encKey, iv); const cipher = await encryptAesCbc(data, encKey, iv);
const mac = await hmacSha256(macKey, concatBytes(iv, cipher)); const mac = await hmacSha256(macKey, concatBytes(iv, cipher));
const out = new Uint8Array(1 + iv.length + mac.length + cipher.length); const out = new Uint8Array(1 + iv.length + mac.length + cipher.length);
@@ -179,7 +213,7 @@ export async function decryptBwFileData(encrypted: Uint8Array, encKey: Uint8Arra
} }
export async function encryptBw(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<string> { export async function encryptBw(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(16)); const iv = requireWebCrypto().getRandomValues(new Uint8Array(16));
const cipher = await encryptAesCbc(data, encKey, iv); const cipher = await encryptAesCbc(data, encKey, iv);
const mac = await hmacSha256(macKey, concatBytes(iv, cipher)); const mac = await hmacSha256(macKey, concatBytes(iv, cipher));
return `2.${bytesToBase64(iv)}|${bytesToBase64(cipher)}|${bytesToBase64(mac)}`; return `2.${bytesToBase64(iv)}|${bytesToBase64(cipher)}|${bytesToBase64(mac)}`;
@@ -556,8 +590,9 @@ export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now())
message[i] = c & 0xff; message[i] = c & 0xff;
c = Math.floor(c / 256); c = Math.floor(c / 256);
} }
const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: algorithm }, false, ['sign']); const subtle = requireWebCrypto().subtle;
const hs = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(message))); const key = await subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: algorithm }, false, ['sign']);
const hs = new Uint8Array(await subtle.sign('HMAC', key, toBufferSource(message)));
const offset = hs[hs.length - 1] & 0x0f; const offset = hs[hs.length - 1] & 0x0f;
const bin = ((hs[offset] & 0x7f) << 24) | ((hs[offset + 1] & 0xff) << 16) | ((hs[offset + 2] & 0xff) << 8) | (hs[offset + 3] & 0xff); const bin = ((hs[offset] & 0x7f) << 24) | ((hs[offset + 1] & 0xff) << 16) | ((hs[offset + 2] & 0xff) << 8) | (hs[offset + 3] & 0xff);
let code = (bin % (10 ** digits)).toString().padStart(digits, '0'); let code = (bin % (10 ** digits)).toString().padStart(digits, '0');
+1
View File
@@ -238,6 +238,7 @@ export function translateServerError(message: string | null | undefined, fallbac
'WebDAV server URL is required': 'txt_backup_error_webdav_url_required', 'WebDAV server URL is required': 'txt_backup_error_webdav_url_required',
'WebDAV server URL must start with http:// or https://': 'txt_backup_error_webdav_url_protocol', 'WebDAV server URL must start with http:// or https://': 'txt_backup_error_webdav_url_protocol',
'WebDAV username is required': 'txt_backup_error_webdav_username_required', 'WebDAV username is required': 'txt_backup_error_webdav_username_required',
'Secure browser cryptography is unavailable. Open NodeWarden over HTTPS in a supported browser.': 'txt_web_crypto_unavailable',
'masterPasswordHash is required': 'txt_server_error_master_password_hash_required', 'masterPasswordHash is required': 'txt_server_error_master_password_hash_required',
'masterPasswordHash or userVerificationToken is required': 'txt_server_error_master_password_or_verification_required', 'masterPasswordHash or userVerificationToken is required': 'txt_server_error_master_password_or_verification_required',
}[normalized]; }[normalized];
+1
View File
@@ -511,6 +511,7 @@ const de: Record<string, string> = {
"txt_create_account": "Konto erstellen", "txt_create_account": "Konto erstellen",
"txt_registering": "Konto wird erstellt...", "txt_registering": "Konto wird erstellt...",
"txt_register_failed": "Registrierung fehlgeschlagen", "txt_register_failed": "Registrierung fehlgeschlagen",
"txt_web_crypto_unavailable": "Sichere Browser-Kryptografie ist nicht verfügbar. Öffnen Sie NodeWarden über HTTPS in einem unterstützten Browser.",
"txt_create_folder": "Ordner erstellen", "txt_create_folder": "Ordner erstellen",
"txt_create_folder_failed": "Fehler beim Erstellen des Ordners", "txt_create_folder_failed": "Fehler beim Erstellen des Ordners",
"txt_create_item_failed": "Fehler beim Erstellen des Eintrags", "txt_create_item_failed": "Fehler beim Erstellen des Eintrags",
+1
View File
@@ -534,6 +534,7 @@ const en: Record<string, string> = {
"txt_create_account": "Create Account", "txt_create_account": "Create Account",
"txt_registering": "Creating account...", "txt_registering": "Creating account...",
"txt_register_failed": "Register failed", "txt_register_failed": "Register failed",
"txt_web_crypto_unavailable": "Secure browser cryptography is unavailable. Open NodeWarden over HTTPS in a supported browser.",
"txt_create_folder": "Create Folder", "txt_create_folder": "Create Folder",
"txt_create_folder_failed": "Create folder failed", "txt_create_folder_failed": "Create folder failed",
"txt_create_item_failed": "Create item failed", "txt_create_item_failed": "Create item failed",
+1
View File
@@ -511,6 +511,7 @@ const es: Record<string, string> = {
"txt_create_account": "Crear cuenta", "txt_create_account": "Crear cuenta",
"txt_registering": "Creando cuenta...", "txt_registering": "Creando cuenta...",
"txt_register_failed": "Error al registrarse", "txt_register_failed": "Error al registrarse",
"txt_web_crypto_unavailable": "La criptografía segura del navegador no está disponible. Abra NodeWarden mediante HTTPS en un navegador compatible.",
"txt_create_folder": "Crear carpeta", "txt_create_folder": "Crear carpeta",
"txt_create_folder_failed": "Error al crear carpeta", "txt_create_folder_failed": "Error al crear carpeta",
"txt_create_item_failed": "Error al crear elemento", "txt_create_item_failed": "Error al crear elemento",
+1
View File
@@ -511,6 +511,7 @@ const fi: Record<string, string> = {
"txt_create_account": "Luo tili", "txt_create_account": "Luo tili",
"txt_registering": "Luodaan tiliä...", "txt_registering": "Luodaan tiliä...",
"txt_register_failed": "Rekisteröinti epäonnistui", "txt_register_failed": "Rekisteröinti epäonnistui",
"txt_web_crypto_unavailable": "Selaimen suojattu salaus ei ole käytettävissä. Avaa NodeWarden HTTPS-yhteydellä tuetussa selaimessa.",
"txt_create_folder": "Luo kansio", "txt_create_folder": "Luo kansio",
"txt_create_folder_failed": "Kansion luonti epäonnistui", "txt_create_folder_failed": "Kansion luonti epäonnistui",
"txt_create_item_failed": "Nimikkeen luonti epäonnistui", "txt_create_item_failed": "Nimikkeen luonti epäonnistui",
+1
View File
@@ -511,6 +511,7 @@ const fr: Record<string, string> = {
"txt_create_account": "Créer un compte", "txt_create_account": "Créer un compte",
"txt_registering": "Création du compte...", "txt_registering": "Création du compte...",
"txt_register_failed": "L'inscription a échoué", "txt_register_failed": "L'inscription a échoué",
"txt_web_crypto_unavailable": "La cryptographie sécurisée du navigateur nest pas disponible. Ouvrez NodeWarden via HTTPS dans un navigateur compatible.",
"txt_create_folder": "Créer un dossier", "txt_create_folder": "Créer un dossier",
"txt_create_folder_failed": "La création du dossier a échoué", "txt_create_folder_failed": "La création du dossier a échoué",
"txt_create_item_failed": "La création de l'élément a échoué", "txt_create_item_failed": "La création de l'élément a échoué",
+1
View File
@@ -511,6 +511,7 @@ const it: Record<string, string> = {
"txt_create_account": "Crea Account", "txt_create_account": "Crea Account",
"txt_registering": "Creazione Account in corso...", "txt_registering": "Creazione Account in corso...",
"txt_register_failed": "Registrazione fallita", "txt_register_failed": "Registrazione fallita",
"txt_web_crypto_unavailable": "La crittografia sicura del browser non è disponibile. Apri NodeWarden tramite HTTPS in un browser supportato.",
"txt_create_folder": "Crea cartella", "txt_create_folder": "Crea cartella",
"txt_create_folder_failed": "Impossibile creare la cartella", "txt_create_folder_failed": "Impossibile creare la cartella",
"txt_create_item_failed": "Impossibile creare l'elemento", "txt_create_item_failed": "Impossibile creare l'elemento",
+1
View File
@@ -511,6 +511,7 @@ const ru: Record<string, string> = {
"txt_create_account": "Создать учетную запись", "txt_create_account": "Создать учетную запись",
"txt_registering": "Создание учетной записи...", "txt_registering": "Создание учетной записи...",
"txt_register_failed": "Не удалось зарегистрироваться", "txt_register_failed": "Не удалось зарегистрироваться",
"txt_web_crypto_unavailable": "Безопасная криптография браузера недоступна. Откройте NodeWarden по HTTPS в поддерживаемом браузере.",
"txt_create_folder": "Создать папку", "txt_create_folder": "Создать папку",
"txt_create_folder_failed": "Создать папку не удалось", "txt_create_folder_failed": "Создать папку не удалось",
"txt_create_item_failed": "Создать элемент не удалось", "txt_create_item_failed": "Создать элемент не удалось",
+1
View File
@@ -511,6 +511,7 @@ const sv: Record<string, string> = {
"txt_create_account": "Skapa konto", "txt_create_account": "Skapa konto",
"txt_registering": "Skapar konto...", "txt_registering": "Skapar konto...",
"txt_register_failed": "Registrering misslyckades", "txt_register_failed": "Registrering misslyckades",
"txt_web_crypto_unavailable": "Säker webbläsarkryptografi är inte tillgänglig. Öppna NodeWarden via HTTPS i en webbläsare som stöds.",
"txt_create_folder": "Skapa mapp", "txt_create_folder": "Skapa mapp",
"txt_create_folder_failed": "Misslyckades med att skapa mapp", "txt_create_folder_failed": "Misslyckades med att skapa mapp",
"txt_create_item_failed": "Misslyckades med att skapa objekt", "txt_create_item_failed": "Misslyckades med att skapa objekt",
+1
View File
@@ -514,6 +514,7 @@ const zhCN: Record<string, string> = {
"txt_create_account": "创建账户", "txt_create_account": "创建账户",
"txt_registering": "正在注册...", "txt_registering": "正在注册...",
"txt_register_failed": "注册失败", "txt_register_failed": "注册失败",
"txt_web_crypto_unavailable": "当前浏览器环境无法使用安全加密。请通过 HTTPS 打开 NodeWarden,并使用受支持的现代浏览器。",
"txt_create_folder": "创建文件夹", "txt_create_folder": "创建文件夹",
"txt_create_folder_failed": "创建文件夹失败", "txt_create_folder_failed": "创建文件夹失败",
"txt_create_item_failed": "创建项目失败", "txt_create_item_failed": "创建项目失败",
+1
View File
@@ -514,6 +514,7 @@ const zhTW: Record<string, string> = {
"txt_create_account": "創建賬戶", "txt_create_account": "創建賬戶",
"txt_registering": "正在註冊...", "txt_registering": "正在註冊...",
"txt_register_failed": "註冊失敗", "txt_register_failed": "註冊失敗",
"txt_web_crypto_unavailable": "目前瀏覽器環境無法使用安全加密。請透過 HTTPS 開啟 NodeWarden,並使用受支援的現代瀏覽器。",
"txt_create_folder": "創建文件夾", "txt_create_folder": "創建文件夾",
"txt_create_folder_failed": "創建文件夾失敗", "txt_create_folder_failed": "創建文件夾失敗",
"txt_create_item_failed": "創建項目失敗", "txt_create_item_failed": "創建項目失敗",
+29
View File
@@ -102,6 +102,28 @@ async function appShellNavigation(request) {
); );
} }
async function connectorNavigation(request) {
const runtimeCache = await caches.open(RUNTIME_CACHE);
try {
const response = await fetch(request);
if (isCacheableResponse(response)) {
await runtimeCache.put(request, response.clone());
await trimRuntimeCache(runtimeCache, 120);
}
return response;
} catch {
const shellCache = await caches.open(APP_SHELL_CACHE);
const cached =
(await shellCache.match(request, { ignoreSearch: true }))
|| (await runtimeCache.match(request, { ignoreSearch: true }))
|| (await matchLegacyRuntimeCache(request));
return cached || new Response('WebAuthn connector is unavailable while offline.', {
status: 503,
headers: { 'Content-Type': 'text/plain; charset=UTF-8' },
});
}
}
async function trimRuntimeCache(cache, maxEntries) { async function trimRuntimeCache(cache, maxEntries) {
const keys = await cache.keys(); const keys = await cache.keys();
if (keys.length <= maxEntries) return; if (keys.length <= maxEntries) return;
@@ -145,6 +167,13 @@ self.addEventListener('fetch', (event) => {
const url = new URL(request.url); const url = new URL(request.url);
if (NEVER_CACHE_PATH_RE.test(url.pathname)) return; if (NEVER_CACHE_PATH_RE.test(url.pathname)) return;
// Connector navigations are protocol pages, not application routes. They must
// never be replaced with the SPA shell, even when the device is offline.
if (url.pathname.endsWith('-connector.html')) {
event.respondWith(connectorNavigation(request));
return;
}
if (request.mode === 'navigate') { if (request.mode === 'navigate') {
event.respondWith(appShellNavigation(request)); event.respondWith(appShellNavigation(request));
if (navigator.onLine !== false) { if (navigator.onLine !== false) {
+1
View File
@@ -8,6 +8,7 @@ command = "npm run build"
[assets] [assets]
binding = "ASSETS" binding = "ASSETS"
directory = "./dist" directory = "./dist"
html_handling = "none"
not_found_handling = "single-page-application" not_found_handling = "single-page-application"
run_worker_first = true run_worker_first = true
+1
View File
@@ -8,6 +8,7 @@ command = "npm run build"
[assets] [assets]
binding = "ASSETS" binding = "ASSETS"
directory = "./dist" directory = "./dist"
html_handling = "none"
not_found_handling = "single-page-application" not_found_handling = "single-page-application"
run_worker_first = true run_worker_first = true