mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 23:00:10 +00:00
Compare commits
4
Commits
v1.8.0
..
bbbad40bba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbbad40bba | ||
|
|
6ffdf05dc6 | ||
|
|
82d9f61163 | ||
|
|
f761fffd58 |
Generated
+191
-183
@@ -11,27 +11,27 @@
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@zip.js/zip.js": "^2.8.26",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"@zip.js/zip.js": "^2.8.33",
|
||||
"fflate": "^0.8.3",
|
||||
"jsqr": "1.4.0",
|
||||
"lucide-preact": "^1.22.0",
|
||||
"preact": "^10.29.3",
|
||||
"lucide-preact": "^1.25.0",
|
||||
"preact": "^10.29.7",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"wouter": "^3.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260630.1",
|
||||
"@preact/preset-vite": "^2.10.5",
|
||||
"@types/node": "^26.0.1",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"opencc-js": "^1.3.2",
|
||||
"postcss": "^8.5.16",
|
||||
"@preact/preset-vite": "^2.10.6",
|
||||
"@types/node": "^26.1.1",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"opencc-js": "^1.4.1",
|
||||
"postcss": "^8.5.22",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tsx": "^4.22.4",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.3",
|
||||
"wrangler": "^4.105.0"
|
||||
"vite": "^8.1.5",
|
||||
"wrangler": "^4.113.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@@ -613,9 +613,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudflare/workerd-darwin-64": {
|
||||
"version": "1.20260625.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260625.1.tgz",
|
||||
"integrity": "sha512-naCfBv0WnnTQIQPTniqMoUlklOIFjrAcSn1X+IAOhY8aFLF/xGYtFjs1eEE8sFib3ZuChGGpU23FFORVczqr0A==",
|
||||
"version": "1.20260721.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260721.1.tgz",
|
||||
"integrity": "sha512-VivNMhiEdZIB4JBWxf1RMJGROErv53qmQ+dvhjA1evrCouvqRYW718VqDideU3PSV7Ythl5Df48NqZYWoaEHpQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -630,9 +630,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudflare/workerd-darwin-arm64": {
|
||||
"version": "1.20260625.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260625.1.tgz",
|
||||
"integrity": "sha512-jmH6zjp6Wrux46+qtFwDwrj+vd7s5bdwEqeGvdnwE0a4IEeAhKs0L42HQOyID+g5lkrHq9m55+AbhtmRAm63Pw==",
|
||||
"version": "1.20260721.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260721.1.tgz",
|
||||
"integrity": "sha512-k7oye1ZiuwnnBBA2eTMduconr/ud5ZxFtRNTsYwMdmJeeeislw2+M72otrHxxvybCP7JWPPlJ38uhfajpcyhOA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -647,9 +647,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudflare/workerd-linux-64": {
|
||||
"version": "1.20260625.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260625.1.tgz",
|
||||
"integrity": "sha512-MiQkpA/dX8d83Zp64pzHUKfd6ca4cvwxnNobSP6CnXvfESvnNI9pfa+nfwnParla36sPmnYntNkjR7NjRuDeKQ==",
|
||||
"version": "1.20260721.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260721.1.tgz",
|
||||
"integrity": "sha512-hon0lW4ZQ4boAVgaw+0ZFTNS8v5MWPWvK0HZnt4tDpKYnDUviLZawtUW3KqvFmCQTipVHl1S34j3J8Eqb93hGQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -664,9 +664,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudflare/workerd-linux-arm64": {
|
||||
"version": "1.20260625.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260625.1.tgz",
|
||||
"integrity": "sha512-LxxW7Qv60Xvv37+w6gUSDpYZziyqMy+cZWd9IvSA5ehVgKAxmzEaYPMiSZlxk32nbIWL9u/tfjXYCOKJ4Lo+XQ==",
|
||||
"version": "1.20260721.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260721.1.tgz",
|
||||
"integrity": "sha512-nAl+HRQqpX5b7xVwWcvLPZmCk8NQ2yjI0yvJTWcHiRswbMEg1ZZckVmjJUAn0PHzZARbCSyIV7v3UjM+SPRmIQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -681,9 +681,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudflare/workerd-windows-64": {
|
||||
"version": "1.20260625.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260625.1.tgz",
|
||||
"integrity": "sha512-LH6iIX1HHaTwVKV5VokDxxUErXJzQoNZFRwVm7Vx/3fB/ApcTcRCUaMqcxI4as94jEUqg+pmX5czOndiveohow==",
|
||||
"version": "1.20260721.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260721.1.tgz",
|
||||
"integrity": "sha512-9paFG5cMTKz/CRixnEEnZbe5uvFPBFSDthxJHANfCWhUtBj49GSL1FPIokIg+Q+H8DGJEExU0lL92LtxD0lTxQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1863,9 +1863,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.138.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
|
||||
"integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
|
||||
"version": "0.139.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
||||
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -2070,9 +2070,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@preact/preset-vite": {
|
||||
"version": "2.10.5",
|
||||
"resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.5.tgz",
|
||||
"integrity": "sha512-p0vJpxiVO7KWWazWny3LUZ+saXyZKWv6Ju0bYMWNJRp2YveufRPgSUB1C4MTqGJfz07EehMgfN+AJNwQy+w6Iw==",
|
||||
"version": "2.10.6",
|
||||
"resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.6.tgz",
|
||||
"integrity": "sha512-ZZ5RIT2ZVXg8UxRA8VZpoT8CdpDG7RFIqOT4bELqNBp85+HKvQBbgmh3gsoW1UOQXx26TLe+ZRNKI7q281Kckw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2162,9 +2162,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
||||
"integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2179,9 +2179,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz",
|
||||
"integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2196,9 +2196,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz",
|
||||
"integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2213,9 +2213,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz",
|
||||
"integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2230,9 +2230,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz",
|
||||
"integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
|
||||
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2247,9 +2247,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz",
|
||||
"integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2267,9 +2267,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz",
|
||||
"integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2287,9 +2287,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz",
|
||||
"integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -2307,9 +2307,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz",
|
||||
"integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -2327,9 +2327,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz",
|
||||
"integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2347,9 +2347,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz",
|
||||
"integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2367,9 +2367,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz",
|
||||
"integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2384,9 +2384,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz",
|
||||
"integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
|
||||
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -2403,9 +2403,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
|
||||
"integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2420,9 +2420,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz",
|
||||
"integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2506,9 +2506,9 @@
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.101.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz",
|
||||
"integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==",
|
||||
"version": "5.101.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz",
|
||||
"integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -2516,12 +2516,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.101.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz",
|
||||
"integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==",
|
||||
"version": "5.101.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz",
|
||||
"integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.101.2"
|
||||
"@tanstack/query-core": "5.101.4"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -2564,9 +2564,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz",
|
||||
"integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==",
|
||||
"version": "26.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
|
||||
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2574,9 +2574,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@zip.js/zip.js": {
|
||||
"version": "2.8.26",
|
||||
"resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.26.tgz",
|
||||
"integrity": "sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==",
|
||||
"version": "2.8.33",
|
||||
"resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.33.tgz",
|
||||
"integrity": "sha512-Mc+s4DdDl9lmhFmkOmNDryC/b4rZm7y2/qBUQTCwPYGkQ8dkCeykILqEYBd+14ATuj93XWZJBrPe2x+cF9LcGA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"bun": ">=0.7.0",
|
||||
@@ -2640,9 +2640,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.5.2",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.2.tgz",
|
||||
"integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==",
|
||||
"version": "10.5.4",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz",
|
||||
"integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2660,8 +2660,8 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.28.4",
|
||||
"caniuse-lite": "^1.0.30001799",
|
||||
"browserslist": "^4.28.6",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
"fraction.js": "^5.3.4",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss-value-parser": "^4.2.0"
|
||||
@@ -2677,9 +2677,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer/node_modules/browserslist": {
|
||||
"version": "4.28.4",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
|
||||
"integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
|
||||
"version": "4.28.7",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
|
||||
"integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2697,10 +2697,10 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.38",
|
||||
"caniuse-lite": "^1.0.30001799",
|
||||
"electron-to-chromium": "^1.5.376",
|
||||
"node-releases": "^2.0.48",
|
||||
"baseline-browser-mapping": "^2.10.44",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
"electron-to-chromium": "^1.5.393",
|
||||
"node-releases": "^2.0.51",
|
||||
"update-browserslist-db": "^1.2.3"
|
||||
},
|
||||
"bin": {
|
||||
@@ -2721,9 +2721,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.40",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
|
||||
"integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==",
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz",
|
||||
"integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -2818,9 +2818,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001799",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
|
||||
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
|
||||
"version": "1.0.30001806",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
||||
"integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3052,9 +3052,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.381",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz",
|
||||
"integrity": "sha512-n9Wa6yB+vDsGuA8AKbl/0z7HbvWqt5jxIdvr1IUicd0ryPrk7/xzwqLv8D9AbbvZ6avVNtXYLTfmgFHkwkyelg==",
|
||||
"version": "1.5.395",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz",
|
||||
"integrity": "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -3765,9 +3765,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-preact": {
|
||||
"version": "1.22.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-preact/-/lucide-preact-1.22.0.tgz",
|
||||
"integrity": "sha512-zFaBtoaWQgvapVEI96M3b5iUOlAEvpUfDuW3Gs8K1RHDyoOTrnXm+Cz5tupm8StKbRKn3W/YKIPolllf5voVDw==",
|
||||
"version": "1.25.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-preact/-/lucide-preact-1.25.0.tgz",
|
||||
"integrity": "sha512-bGGOLwNk4khtQYhTgsc5/MLYvZAf5KONZUSH4Lbc7vhtfmK0hfCaucKFnN6GBHkDozx4bmaoN2ulmn3u8r0W1g==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"preact": "^10.27.2"
|
||||
@@ -3821,16 +3821,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/miniflare": {
|
||||
"version": "4.20260625.0",
|
||||
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260625.0.tgz",
|
||||
"integrity": "sha512-3kKXwRUObJsnBYPBgR0NiNZYKF/yv8GFyha1cx2EeAEraxNODgRVcyeRo+F1ok1tg5Mg7iUpOWSkknQTHuFhwA==",
|
||||
"version": "4.20260721.0",
|
||||
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260721.0.tgz",
|
||||
"integrity": "sha512-fBLaCxZ2i/nPH8iyLzvza0C8/sSF4sjD1ma1Skf+pkZVK0TlaW5ujHJlUHwcwR66v2JZt+Q28d4DCX/oaLG0cA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "0.8.1",
|
||||
"sharp": "0.34.5",
|
||||
"undici": "7.28.0",
|
||||
"workerd": "1.20260625.1",
|
||||
"workerd": "1.20260721.1",
|
||||
"ws": "8.21.0",
|
||||
"youch": "4.1.0-beta.10"
|
||||
},
|
||||
@@ -3867,9 +3867,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3897,9 +3897,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.50",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
|
||||
"integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
|
||||
"version": "2.0.51",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
||||
"integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3964,9 +3964,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/opencc-js": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/opencc-js/-/opencc-js-1.3.2.tgz",
|
||||
"integrity": "sha512-lO4Kq8J4TcPTa8qHcx5qazQCn+NM68kNwLJOQ58DG9MV8v25XJxByMB74zDGmr3LjJMGqDdrvQfoCi3FO0SC2A==",
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/opencc-js/-/opencc-js-1.4.1.tgz",
|
||||
"integrity": "sha512-2lPLcrg7cnh1ATSduOxhnk/jq6KlR6w+5Ihl4wF7Z5e5Rva4vxTyugV5rZ7EiUCOZl+EDOLetIix+BhgYDCC+A==",
|
||||
"dev": true,
|
||||
"license": "MIT AND Apache-2.0"
|
||||
},
|
||||
@@ -3999,9 +3999,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4032,9 +4032,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.16",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
||||
"version": "8.5.22",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz",
|
||||
"integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4052,7 +4052,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.16",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -4188,13 +4188,21 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.29.3",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.3.tgz",
|
||||
"integrity": "sha512-D9NL1GAnJZhc3RndVs4gDdxEeU9TcHgywMrhhOsnpdlvFjdbx0gAsLUnH6JEhlJH5giL7Tx5biWPUSEXE/HPzw==",
|
||||
"version": "10.29.7",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz",
|
||||
"integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact-render-to-string": ">=5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"preact-render-to-string": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pvtsutils": {
|
||||
@@ -4337,13 +4345,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
|
||||
"integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.138.0",
|
||||
"@oxc-project/types": "=0.139.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -4353,21 +4361,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.1.4",
|
||||
"@rolldown/binding-darwin-arm64": "1.1.4",
|
||||
"@rolldown/binding-darwin-x64": "1.1.4",
|
||||
"@rolldown/binding-freebsd-x64": "1.1.4",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.1.4",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.1.4",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.1.4",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.1.4",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.1.4",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.1.4",
|
||||
"@rolldown/binding-linux-x64-musl": "1.1.4",
|
||||
"@rolldown/binding-openharmony-arm64": "1.1.4",
|
||||
"@rolldown/binding-wasm32-wasi": "1.1.4",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.1.4",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.4"
|
||||
"@rolldown/binding-android-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-x64": "1.1.5",
|
||||
"@rolldown/binding-freebsd-x64": "1.1.5",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.1.5",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-musl": "1.1.5",
|
||||
"@rolldown/binding-openharmony-arm64": "1.1.5",
|
||||
"@rolldown/binding-wasm32-wasi": "1.1.5",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.1.5",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
@@ -4646,9 +4654,9 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
||||
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
|
||||
"version": "4.23.1",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",
|
||||
"integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4697,9 +4705,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "8.5.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz",
|
||||
"integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==",
|
||||
"version": "8.8.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.8.0.tgz",
|
||||
"integrity": "sha512-ubshXMXwF3MQIMF1y/WxZdNBnjEKeSg2wF5mcGUtU55YTw34tnVVpKRlLf7ruDXZ5344KokPVX4RBx1wJm64Bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4771,16 +4779,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
||||
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
||||
"version": "8.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
||||
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.16",
|
||||
"rolldown": "~1.1.3",
|
||||
"picomatch": "^4.0.5",
|
||||
"postcss": "^8.5.17",
|
||||
"rolldown": "~1.1.5",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
@@ -4867,9 +4875,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/workerd": {
|
||||
"version": "1.20260625.1",
|
||||
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260625.1.tgz",
|
||||
"integrity": "sha512-GApQvFX52SDM6L4u0+RRnUDB1wJOnEwoXjinkmOPtIyofWBxrlZckdegJSYc1leg++lLZ3+DQ4zMVmBqYVtzfA==",
|
||||
"version": "1.20260721.1",
|
||||
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260721.1.tgz",
|
||||
"integrity": "sha512-b/DWhpV0jTudzQpLhDovcOgBz233386q+3Hbari7CLCNT9UXxjQziSTZ9yCoKdT2K3TSx5jrwlOisq8hlLWXYg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
@@ -4880,11 +4888,11 @@
|
||||
"node": ">=16"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@cloudflare/workerd-darwin-64": "1.20260625.1",
|
||||
"@cloudflare/workerd-darwin-arm64": "1.20260625.1",
|
||||
"@cloudflare/workerd-linux-64": "1.20260625.1",
|
||||
"@cloudflare/workerd-linux-arm64": "1.20260625.1",
|
||||
"@cloudflare/workerd-windows-64": "1.20260625.1"
|
||||
"@cloudflare/workerd-darwin-64": "1.20260721.1",
|
||||
"@cloudflare/workerd-darwin-arm64": "1.20260721.1",
|
||||
"@cloudflare/workerd-linux-64": "1.20260721.1",
|
||||
"@cloudflare/workerd-linux-arm64": "1.20260721.1",
|
||||
"@cloudflare/workerd-windows-64": "1.20260721.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wouter": {
|
||||
@@ -4902,9 +4910,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler": {
|
||||
"version": "4.105.0",
|
||||
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.105.0.tgz",
|
||||
"integrity": "sha512-7dXFH6OLj1Fv0y6ZeRPUxFTkp+duWD7/xxVi/1c0vfOeEYwIFKWB7cdqnY05DvY1Ta3BnqAwRkXfLs8PDj538g==",
|
||||
"version": "4.113.0",
|
||||
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.113.0.tgz",
|
||||
"integrity": "sha512-ROGzSloJv0y21It6Oc9LaruNcu1tdiQ/XzL3Jc3YkFjzXEMXzTqVhA8vQaGMTdZHTjFP0PVcwAHNgaw3gXu4wA==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -4912,10 +4920,10 @@
|
||||
"@cloudflare/unenv-preset": "2.16.1",
|
||||
"blake3-wasm": "2.1.5",
|
||||
"esbuild": "0.28.1",
|
||||
"miniflare": "4.20260625.0",
|
||||
"miniflare": "4.20260721.0",
|
||||
"path-to-regexp": "6.3.0",
|
||||
"unenv": "2.0.0-rc.24",
|
||||
"workerd": "1.20260625.1"
|
||||
"workerd": "1.20260721.1"
|
||||
},
|
||||
"bin": {
|
||||
"cf-wrangler": "bin/cf-wrangler.js",
|
||||
@@ -4929,7 +4937,7 @@
|
||||
"fsevents": "2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260625.1"
|
||||
"@cloudflare/workers-types": "^5.20260721.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cloudflare/workers-types": {
|
||||
|
||||
+17
-12
@@ -15,6 +15,11 @@
|
||||
"domains:sync": "node scripts/sync-global-domains.mjs",
|
||||
"i18n": "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: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"
|
||||
@@ -50,26 +55,26 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260630.1",
|
||||
"@preact/preset-vite": "^2.10.5",
|
||||
"@types/node": "^26.0.1",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"opencc-js": "^1.3.2",
|
||||
"postcss": "^8.5.16",
|
||||
"@preact/preset-vite": "^2.10.6",
|
||||
"@types/node": "^26.1.1",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"opencc-js": "^1.4.1",
|
||||
"postcss": "^8.5.22",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tsx": "^4.22.4",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.3",
|
||||
"wrangler": "^4.105.0"
|
||||
"vite": "^8.1.5",
|
||||
"wrangler": "^4.113.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@zip.js/zip.js": "^2.8.26",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"@zip.js/zip.js": "^2.8.33",
|
||||
"fflate": "^0.8.3",
|
||||
"jsqr": "1.4.0",
|
||||
"lucide-preact": "^1.22.0",
|
||||
"preact": "^10.29.3",
|
||||
"lucide-preact": "^1.25.0",
|
||||
"preact": "^10.29.7",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"wouter": "^3.10.0"
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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'\)/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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
@@ -36,6 +36,7 @@ import { jsonResponse, unsupportedResponse } from './utils/response';
|
||||
import { StorageService } from './services/storage';
|
||||
import type { Env } from './types';
|
||||
import { getConfiguredWebAuthnAllowedOrigins } from './utils/origins';
|
||||
import { buildConfigResponse } from './config-response';
|
||||
|
||||
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | 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 {
|
||||
let decoded: string;
|
||||
try {
|
||||
|
||||
+18
-1
@@ -8,6 +8,13 @@ export const OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS = [
|
||||
'chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn',
|
||||
] 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 {
|
||||
const raw = String(value || '').trim();
|
||||
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(
|
||||
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>
|
||||
): 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(',')) {
|
||||
const origin = normalizeOrigin(item);
|
||||
if (origin) seen.add(origin);
|
||||
|
||||
+19
-3
@@ -3,6 +3,7 @@ import type { Env } from '../types';
|
||||
import {
|
||||
isBrowserExtensionOrigin,
|
||||
isConfiguredWebAuthnAllowedOrigin,
|
||||
isOfficialBitwardenDesktopOrigin,
|
||||
normalizeOrigin,
|
||||
} from './origins';
|
||||
|
||||
@@ -48,7 +49,10 @@ function getCorsPolicy(request: Request, env: Env): { allowOrigin: string | null
|
||||
if (origin === url.origin) {
|
||||
return { allowOrigin: origin, allowCredentials: true };
|
||||
}
|
||||
if (isBrowserExtensionOrigin(origin) && isConfiguredWebAuthnAllowedOrigin(env, origin)) {
|
||||
if (
|
||||
(isBrowserExtensionOrigin(origin) || isOfficialBitwardenDesktopOrigin(origin))
|
||||
&& isConfiguredWebAuthnAllowedOrigin(env, origin)
|
||||
) {
|
||||
return { allowOrigin: origin, allowCredentials: true };
|
||||
}
|
||||
if (isWildcardCorsPath(url.pathname)) {
|
||||
@@ -100,10 +104,22 @@ export function applyCors(
|
||||
headers.set(k, v);
|
||||
}
|
||||
// Security headers applied to every response.
|
||||
headers.set('X-Frame-Options', 'DENY');
|
||||
headers.set('X-Content-Type-Options', 'nosniff');
|
||||
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:");
|
||||
}
|
||||
return new Response(response.body, {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 type { AuthorizedDevice } from '../types';
|
||||
import type {
|
||||
@@ -428,14 +436,15 @@ export async function registerAccount(args: {
|
||||
}): Promise<{ ok: true } | { ok: false; message: string }> {
|
||||
try {
|
||||
const { email, name, password, masterPasswordHint, inviteCode, fallbackIterations } = args;
|
||||
const webCrypto = requireWebCrypto();
|
||||
const masterKey = await pbkdf2(password, email, fallbackIterations, 32);
|
||||
const masterHash = await pbkdf2(masterKey, password, 1, 32);
|
||||
const encKey = await hkdfExpand(masterKey, 'enc', 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 keyPair = await crypto.subtle.generateKey(
|
||||
const keyPair = await webCrypto.subtle.generateKey(
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
modulusLength: 2048,
|
||||
@@ -445,8 +454,8 @@ export async function registerAccount(args: {
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey));
|
||||
const privateKey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey));
|
||||
const publicKey = new Uint8Array(await webCrypto.subtle.exportKey('spki', keyPair.publicKey));
|
||||
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 resp = await fetch('/api/accounts/register', {
|
||||
@@ -474,6 +483,9 @@ export async function registerAccount(args: {
|
||||
}
|
||||
return { ok: true };
|
||||
} 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') };
|
||||
}
|
||||
}
|
||||
|
||||
+52
-17
@@ -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 {
|
||||
let s = '';
|
||||
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> {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -51,7 +82,7 @@ function getHmacSha256Key(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||
return getCachedCryptoKey(
|
||||
hmacSha256KeyCache,
|
||||
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(
|
||||
aesCbcEncryptKeyCache,
|
||||
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(
|
||||
aesCbcDecryptKeyCache,
|
||||
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> {
|
||||
const pwdBytes = typeof passwordOrBytes === 'string' ? new TextEncoder().encode(passwordOrBytes) : passwordOrBytes;
|
||||
const saltBytes = typeof saltOrBytes === 'string' ? new TextEncoder().encode(saltOrBytes) : saltOrBytes;
|
||||
const key = await crypto.subtle.importKey('raw', toBufferSource(pwdBytes), 'PBKDF2', false, ['deriveBits']);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
const subtle = requireWebCrypto().subtle;
|
||||
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 },
|
||||
key,
|
||||
keyLen * 8
|
||||
@@ -99,7 +131,8 @@ export async function pbkdf2(
|
||||
|
||||
export async function hkdfExpand(prk: Uint8Array, info: string, length: number): Promise<Uint8Array> {
|
||||
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);
|
||||
let previous = new Uint8Array(0);
|
||||
let offset = 0;
|
||||
@@ -110,7 +143,7 @@ export async function hkdfExpand(prk: Uint8Array, info: string, length: number):
|
||||
input.set(previous, 0);
|
||||
input.set(infoBytes, previous.length);
|
||||
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);
|
||||
result.set(previous.slice(0, copyLen), offset);
|
||||
offset += copyLen;
|
||||
@@ -134,28 +167,29 @@ export async function hkdf(
|
||||
info: toBufferSource(infoBytes),
|
||||
hash: 'SHA-256',
|
||||
};
|
||||
const key = await crypto.subtle.importKey('raw', toBufferSource(ikm), 'HKDF', false, ['deriveBits']);
|
||||
const bits = await crypto.subtle.deriveBits(params, key, outputByteSize * 8);
|
||||
const subtle = requireWebCrypto().subtle;
|
||||
const key = await subtle.importKey('raw', toBufferSource(ikm), 'HKDF', false, ['deriveBits']);
|
||||
const bits = await subtle.deriveBits(params, key, outputByteSize * 8);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise<Uint8Array> {
|
||||
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> {
|
||||
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> {
|
||||
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> {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = requireWebCrypto().getRandomValues(new Uint8Array(16));
|
||||
const cipher = await encryptAesCbc(data, encKey, iv);
|
||||
const mac = await hmacSha256(macKey, concatBytes(iv, cipher));
|
||||
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> {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = requireWebCrypto().getRandomValues(new Uint8Array(16));
|
||||
const cipher = await encryptAesCbc(data, encKey, iv);
|
||||
const mac = await hmacSha256(macKey, concatBytes(iv, cipher));
|
||||
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;
|
||||
c = Math.floor(c / 256);
|
||||
}
|
||||
const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: algorithm }, false, ['sign']);
|
||||
const hs = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(message)));
|
||||
const subtle = requireWebCrypto().subtle;
|
||||
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 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');
|
||||
|
||||
@@ -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 must start with http:// or https://': 'txt_backup_error_webdav_url_protocol',
|
||||
'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 or userVerificationToken is required': 'txt_server_error_master_password_or_verification_required',
|
||||
}[normalized];
|
||||
|
||||
@@ -511,6 +511,7 @@ const de: Record<string, string> = {
|
||||
"txt_create_account": "Konto erstellen",
|
||||
"txt_registering": "Konto wird erstellt...",
|
||||
"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_failed": "Fehler beim Erstellen des Ordners",
|
||||
"txt_create_item_failed": "Fehler beim Erstellen des Eintrags",
|
||||
|
||||
@@ -534,6 +534,7 @@ const en: Record<string, string> = {
|
||||
"txt_create_account": "Create Account",
|
||||
"txt_registering": "Creating account...",
|
||||
"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_failed": "Create folder failed",
|
||||
"txt_create_item_failed": "Create item failed",
|
||||
|
||||
@@ -511,6 +511,7 @@ const es: Record<string, string> = {
|
||||
"txt_create_account": "Crear cuenta",
|
||||
"txt_registering": "Creando cuenta...",
|
||||
"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_failed": "Error al crear carpeta",
|
||||
"txt_create_item_failed": "Error al crear elemento",
|
||||
|
||||
@@ -511,6 +511,7 @@ const fi: Record<string, string> = {
|
||||
"txt_create_account": "Luo tili",
|
||||
"txt_registering": "Luodaan tiliä...",
|
||||
"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_failed": "Kansion luonti epäonnistui",
|
||||
"txt_create_item_failed": "Nimikkeen luonti epäonnistui",
|
||||
|
||||
@@ -511,6 +511,7 @@ const fr: Record<string, string> = {
|
||||
"txt_create_account": "Créer un compte",
|
||||
"txt_registering": "Création du compte...",
|
||||
"txt_register_failed": "L'inscription a échoué",
|
||||
"txt_web_crypto_unavailable": "La cryptographie sécurisée du navigateur n’est pas disponible. Ouvrez NodeWarden via HTTPS dans un navigateur compatible.",
|
||||
"txt_create_folder": "Créer un dossier",
|
||||
"txt_create_folder_failed": "La création du dossier a échoué",
|
||||
"txt_create_item_failed": "La création de l'élément a échoué",
|
||||
|
||||
@@ -511,6 +511,7 @@ const it: Record<string, string> = {
|
||||
"txt_create_account": "Crea Account",
|
||||
"txt_registering": "Creazione Account in corso...",
|
||||
"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_failed": "Impossibile creare la cartella",
|
||||
"txt_create_item_failed": "Impossibile creare l'elemento",
|
||||
|
||||
@@ -511,6 +511,7 @@ const ru: Record<string, string> = {
|
||||
"txt_create_account": "Создать учетную запись",
|
||||
"txt_registering": "Создание учетной записи...",
|
||||
"txt_register_failed": "Не удалось зарегистрироваться",
|
||||
"txt_web_crypto_unavailable": "Безопасная криптография браузера недоступна. Откройте NodeWarden по HTTPS в поддерживаемом браузере.",
|
||||
"txt_create_folder": "Создать папку",
|
||||
"txt_create_folder_failed": "Создать папку не удалось",
|
||||
"txt_create_item_failed": "Создать элемент не удалось",
|
||||
|
||||
@@ -511,6 +511,7 @@ const sv: Record<string, string> = {
|
||||
"txt_create_account": "Skapa konto",
|
||||
"txt_registering": "Skapar konto...",
|
||||
"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_failed": "Misslyckades med att skapa mapp",
|
||||
"txt_create_item_failed": "Misslyckades med att skapa objekt",
|
||||
|
||||
@@ -514,6 +514,7 @@ const zhCN: Record<string, string> = {
|
||||
"txt_create_account": "创建账户",
|
||||
"txt_registering": "正在注册...",
|
||||
"txt_register_failed": "注册失败",
|
||||
"txt_web_crypto_unavailable": "当前浏览器环境无法使用安全加密。请通过 HTTPS 打开 NodeWarden,并使用受支持的现代浏览器。",
|
||||
"txt_create_folder": "创建文件夹",
|
||||
"txt_create_folder_failed": "创建文件夹失败",
|
||||
"txt_create_item_failed": "创建项目失败",
|
||||
|
||||
@@ -514,6 +514,7 @@ const zhTW: Record<string, string> = {
|
||||
"txt_create_account": "創建賬戶",
|
||||
"txt_registering": "正在註冊...",
|
||||
"txt_register_failed": "註冊失敗",
|
||||
"txt_web_crypto_unavailable": "目前瀏覽器環境無法使用安全加密。請透過 HTTPS 開啟 NodeWarden,並使用受支援的現代瀏覽器。",
|
||||
"txt_create_folder": "創建文件夾",
|
||||
"txt_create_folder_failed": "創建文件夾失敗",
|
||||
"txt_create_item_failed": "創建項目失敗",
|
||||
|
||||
@@ -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) {
|
||||
const keys = await cache.keys();
|
||||
if (keys.length <= maxEntries) return;
|
||||
@@ -145,6 +167,13 @@ self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(request.url);
|
||||
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') {
|
||||
event.respondWith(appShellNavigation(request));
|
||||
if (navigator.onLine !== false) {
|
||||
|
||||
@@ -8,6 +8,7 @@ command = "npm run build"
|
||||
[assets]
|
||||
binding = "ASSETS"
|
||||
directory = "./dist"
|
||||
html_handling = "none"
|
||||
not_found_handling = "single-page-application"
|
||||
run_worker_first = true
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ command = "npm run build"
|
||||
[assets]
|
||||
binding = "ASSETS"
|
||||
directory = "./dist"
|
||||
html_handling = "none"
|
||||
not_found_handling = "single-page-application"
|
||||
run_worker_first = true
|
||||
|
||||
|
||||
Reference in New Issue
Block a user