mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 06:50:10 +00:00
Compare commits
9
Commits
39c076b2e1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
652952379b | ||
|
|
34fd2f0259 | ||
|
|
df493b4697 | ||
|
|
f6403b8cab | ||
|
|
a0128908e1 | ||
|
|
8e5d9e294b | ||
|
|
6ffdf05dc6 | ||
|
|
82d9f61163 | ||
|
|
f761fffd58 |
Generated
+179
-142
@@ -26,7 +26,7 @@
|
|||||||
"@types/node": "^26.0.1",
|
"@types/node": "^26.0.1",
|
||||||
"autoprefixer": "^10.5.2",
|
"autoprefixer": "^10.5.2",
|
||||||
"opencc-js": "^1.3.2",
|
"opencc-js": "^1.3.2",
|
||||||
"postcss": "^8.5.16",
|
"postcss": "^8.5.23",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"tsx": "^4.22.4",
|
"tsx": "^4.22.4",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
@@ -1210,9 +1210,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-darwin-arm64": {
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
"version": "0.34.5",
|
"version": "0.35.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.0.tgz",
|
||||||
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
"integrity": "sha512-ZgaYEwaj+lx/5n4W8GmZ2IYz0PQHjN5eqRcfijWGB+2Aq7ZInZGa0qJyAn6DEtyLuWHRSrmWOqT9q3qqTBvmUQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1223,19 +1223,19 @@
|
|||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
"@img/sharp-libvips-darwin-arm64": "1.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-darwin-x64": {
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
"version": "0.34.5",
|
"version": "0.35.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.0.tgz",
|
||||||
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
"integrity": "sha512-c1z9LFpKB0slQW3RchwBE8iSVzGp70TNjUUO9k4BZwwW4HH7JBGHeIy4b+kk4n/kcBASb9evKCE3/7Slmslgiw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1246,19 +1246,39 @@
|
|||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
"@img/sharp-libvips-darwin-x64": "1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||||
|
"version": "0.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.0.tgz",
|
||||||
|
"integrity": "sha512-Li2KTev0H90kEtnJHkI9xQojXt1AqWmFBMXiPw5kqd1jQgP7gi5HVK/qC5Rmh/59NuAwUuPzzPITmX22NomYYQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@img/sharp-wasm32": "0.35.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
"version": "1.2.4",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.0.tgz",
|
||||||
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
"integrity": "sha512-EKbmBKtyTH+GPFDRw2TgK2oV6hyxxlJVIar4hoTYSNmIwipgMFdxPQqR392GmfdsPGWga0mCFN1cCKjRb9cljw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1273,9 +1293,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
"version": "1.2.4",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.0.tgz",
|
||||||
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
"integrity": "sha512-Pl2OmOvrJ42adUllESxBsG54PfXLo1OYg9i3c5/5Ln/qJ0gZuTM9YMhQJPIbXqwidLRc/c2zuHt4RsrymmNv7A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1290,9 +1310,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
"version": "1.2.4",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.0.tgz",
|
||||||
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
"integrity": "sha512-A8UpHoUDW4DwnXoV6+q3C1s7QLRAHtPDEjWuNZjwHMyoCNZnm0GeNN8ls9f/bsEYTRQRW96C/n34XJQHJ2fT7A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1310,9 +1330,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
"version": "1.2.4",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.0.tgz",
|
||||||
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
"integrity": "sha512-C0SqjoFKnszqa44EQ7xoaT48nnO0lOyXEULfXMWi8krrjOPGYkeK30Okzla6ATbBYsyZ0ySinK0FVkpv3DwzfQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1330,9 +1350,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||||
"version": "1.2.4",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.0.tgz",
|
||||||
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
"integrity": "sha512-WOpkVxAjFd369iaIzEgNRreFD+gWdUMIGD5zplhNKNeqS6mm5dac3q2AFyCBmzYoAdouzZvRBgxy4z8QHZb4/A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -1350,9 +1370,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||||
"version": "1.2.4",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.0.tgz",
|
||||||
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
"integrity": "sha512-DRWw0mOHusrCCuw2rqP87oLg6PGlkomVDFqw2hIwsSfwWpu4k3XLcBPaKKl6ct/GtL/cwNkgwjV/tc0Mqht3VA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1370,9 +1390,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
"version": "1.2.4",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.0.tgz",
|
||||||
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
"integrity": "sha512-9APy+nFWhHS+kzLgWZfLcyrUd7YqnAQVa4BPOo4xkoHpdoktOAPG4cEr9+Jpl0TtqfVmcMJimNL5qNTyyOHZNA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -1390,9 +1410,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
"version": "1.2.4",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.0.tgz",
|
||||||
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
"integrity": "sha512-y9RNUYDe2A1UAdhLyfeOodGRszQdaEoe4nfOpp/sNVPl2CWIcUyFaDoCh4vPLPxu19803j2naLqZup2WxDXCLA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1410,9 +1430,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
"version": "1.2.4",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.0.tgz",
|
||||||
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
"integrity": "sha512-cC1wkC0Mlucd0KSiGrLkJnB/ZqPvZCntc/Lk7ZnYO5ZSbF2euNek4Xvxafojq+wN1q/W0eprdpUIjUr/EV2PBg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1430,9 +1450,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
"version": "1.2.4",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.0.tgz",
|
||||||
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
"integrity": "sha512-LiYMhUZicB1QG//+RvmYZpXJO8fYRENfp+MZUCnG9aw+AKvGAy9gPaCnuwsPcBFs8EV66M0NNxj9VHcNklE8zw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1450,9 +1470,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-arm": {
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
"version": "0.34.5",
|
"version": "0.35.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.0.tgz",
|
||||||
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
"integrity": "sha512-VVlpEWwizEFIOom0zdoeKuO5nuTswzVE5uHcBNvHzmeHUpNFajY3HFfbQ+zIH4E2kVaZ/yVxmsShW56TtEy4uA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1466,19 +1486,19 @@
|
|||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-arm": "1.2.4"
|
"@img/sharp-libvips-linux-arm": "1.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-arm64": {
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
"version": "0.34.5",
|
"version": "0.35.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.0.tgz",
|
||||||
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
"integrity": "sha512-4+4XHLNT5wDT0roYlHTEmH9lDKt0acf9Tv+3hM3iceOirkxrR404/3WjAYZ9F9CkHrxeRcGLJXbi4vluMZ9O+A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1492,19 +1512,19 @@
|
|||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
"@img/sharp-libvips-linux-arm64": "1.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-ppc64": {
|
"node_modules/@img/sharp-linux-ppc64": {
|
||||||
"version": "0.34.5",
|
"version": "0.35.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.0.tgz",
|
||||||
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
"integrity": "sha512-N3hzbEpUTJC8pWpPVJvgzGxM+so/MAXc8O2s/53B0LL9ZGpfXpME7Wizkc5d/8fRBlBtkDjzoZGDCqqNDHqLEw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -1518,19 +1538,19 @@
|
|||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
"@img/sharp-libvips-linux-ppc64": "1.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-riscv64": {
|
"node_modules/@img/sharp-linux-riscv64": {
|
||||||
"version": "0.34.5",
|
"version": "0.35.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.0.tgz",
|
||||||
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
"integrity": "sha512-l6vmKVPnbS0RhVMbyxP5meAARsbhCnBN4fy31qz0+3a6Rv4jEqfzDrT89y6ZPkCi0AJGnwp2En528yXo401Hpw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1544,19 +1564,19 @@
|
|||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
"@img/sharp-libvips-linux-riscv64": "1.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-s390x": {
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
"version": "0.34.5",
|
"version": "0.35.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.0.tgz",
|
||||||
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
"integrity": "sha512-MYlMiPFiv/EKPAHnp3yNZ9AAWFsxga9c5Bkc6wkar6bqzHLlkGVJHRm0u1ei+VXnZxp3Mz9MG9ZIsI8vSOf3sQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -1570,19 +1590,19 @@
|
|||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
"@img/sharp-libvips-linux-s390x": "1.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-x64": {
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
"version": "0.34.5",
|
"version": "0.35.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.0.tgz",
|
||||||
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
"integrity": "sha512-TYaItB5oj1ioXjhyn2xrR208vf+YuIIcHptQWRRaBmFhvIvL9D72DXN8w75xup0KXA8UdEAhQ9Qb2S49FD/9Cw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1596,19 +1616,19 @@
|
|||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-x64": "1.2.4"
|
"@img/sharp-libvips-linux-x64": "1.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
"version": "0.34.5",
|
"version": "0.35.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.0.tgz",
|
||||||
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
"integrity": "sha512-DSTb6ijQzqe6DdAaOBVqJ/SYf1vO8EW5bK6X6LRXufEBebf2722VCdvBUtZ3rtV0x2ApfPNDy/p7LrrjaWjiyQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1622,19 +1642,19 @@
|
|||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
"@img/sharp-libvips-linuxmusl-arm64": "1.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
"version": "0.34.5",
|
"version": "0.35.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.0.tgz",
|
||||||
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
"integrity": "sha512-K7ykQ+26Rt6+4BTU80AuGgTPIYX86UxiAKT4rcXX/WNTo7k1ZxpKz+TguHnwVpCqQK3B5PK0vZ0ZBe6nz/ib1w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1648,39 +1668,56 @@
|
|||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
"@img/sharp-libvips-linuxmusl-x64": "1.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-wasm32": {
|
"node_modules/@img/sharp-wasm32": {
|
||||||
"version": "0.34.5",
|
"version": "0.35.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.0.tgz",
|
||||||
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
"integrity": "sha512-9woLIFORERCr+6cWu87dQ22J34EExkhc73U1kZW0c+RclQqWetoodByp4dWZ/hN8/KVmTRAx2HOnUwib8AwZdA==",
|
||||||
"cpu": [
|
|
||||||
"wasm32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/runtime": "^1.7.0"
|
"@emnapi/runtime": "^1.11.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||||
|
"version": "0.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.0.tgz",
|
||||||
|
"integrity": "sha512-t+kie1TOyaDM6Dho+f+y0VqIUNhYQaKCUahuZVi0E0frgdiaOaPsDxDW3wfKacUdaNBCnK/ZDBMg33ydvHj8uA==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@img/sharp-wasm32": "0.35.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-win32-arm64": {
|
"node_modules/@img/sharp-win32-arm64": {
|
||||||
"version": "0.34.5",
|
"version": "0.35.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.0.tgz",
|
||||||
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
"integrity": "sha512-M5eKxug0dabbaWgFKvPa3odNs2OpaP+81NASfGKkt4GcYXpNhSu7CaeYxWkLNV6vHmUp4hnCxnxrUyhUJhXbKA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1691,16 +1728,16 @@
|
|||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-win32-ia32": {
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
"version": "0.34.5",
|
"version": "0.35.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.0.tgz",
|
||||||
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
"integrity": "sha512-z0+pZ03QCDvdVN0Ez9IX/yjWC19ikMlXrmdYMwYNLTh2BLPx3hXWPvyqWfquZ0BTO9O6GVOjIVoTcyyacMnWlQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -1711,16 +1748,16 @@
|
|||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^20.9.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-win32-x64": {
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
"version": "0.34.5",
|
"version": "0.35.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.0.tgz",
|
||||||
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
"integrity": "sha512-feNnlz5ZHKr0MY1LPHvZQyJeBkbo4ctsn0D8FvA53VTw5TC63rfEL2UrWbkSBR19htSE7Mw78xYVwdJqoMWVHw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1731,7 +1768,7 @@
|
|||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
@@ -3867,9 +3904,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.12",
|
"version": "3.3.16",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -4032,9 +4069,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.16",
|
"version": "8.5.23",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||||
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -4052,7 +4089,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.12",
|
"nanoid": "^3.3.16",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
@@ -4395,9 +4432,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.8.3",
|
"version": "7.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
"integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==",
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -4408,48 +4445,48 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sharp": {
|
"node_modules/sharp": {
|
||||||
"version": "0.34.5",
|
"version": "0.35.0",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.0.tgz",
|
||||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
"integrity": "sha512-BqvG5XbwPZ4NV0DK90d86leEECMsoa8bO0nqnKWlBDYxri4GJ7c4EDInaF6q20lTh/mATmnDIKWJFfXnoVfH5g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/colour": "^1.0.0",
|
"@img/colour": "^1.1.0",
|
||||||
"detect-libc": "^2.1.2",
|
"detect-libc": "^2.1.2",
|
||||||
"semver": "^7.7.3"
|
"semver": "^7.8.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-darwin-arm64": "0.34.5",
|
"@img/sharp-darwin-arm64": "0.35.0",
|
||||||
"@img/sharp-darwin-x64": "0.34.5",
|
"@img/sharp-darwin-x64": "0.35.0",
|
||||||
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
"@img/sharp-freebsd-wasm32": "0.35.0",
|
||||||
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
"@img/sharp-libvips-darwin-arm64": "1.3.0",
|
||||||
"@img/sharp-libvips-linux-arm": "1.2.4",
|
"@img/sharp-libvips-darwin-x64": "1.3.0",
|
||||||
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
"@img/sharp-libvips-linux-arm": "1.3.0",
|
||||||
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
"@img/sharp-libvips-linux-arm64": "1.3.0",
|
||||||
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
"@img/sharp-libvips-linux-ppc64": "1.3.0",
|
||||||
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
"@img/sharp-libvips-linux-riscv64": "1.3.0",
|
||||||
"@img/sharp-libvips-linux-x64": "1.2.4",
|
"@img/sharp-libvips-linux-s390x": "1.3.0",
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
"@img/sharp-libvips-linux-x64": "1.3.0",
|
||||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
"@img/sharp-libvips-linuxmusl-arm64": "1.3.0",
|
||||||
"@img/sharp-linux-arm": "0.34.5",
|
"@img/sharp-libvips-linuxmusl-x64": "1.3.0",
|
||||||
"@img/sharp-linux-arm64": "0.34.5",
|
"@img/sharp-linux-arm": "0.35.0",
|
||||||
"@img/sharp-linux-ppc64": "0.34.5",
|
"@img/sharp-linux-arm64": "0.35.0",
|
||||||
"@img/sharp-linux-riscv64": "0.34.5",
|
"@img/sharp-linux-ppc64": "0.35.0",
|
||||||
"@img/sharp-linux-s390x": "0.34.5",
|
"@img/sharp-linux-riscv64": "0.35.0",
|
||||||
"@img/sharp-linux-x64": "0.34.5",
|
"@img/sharp-linux-s390x": "0.35.0",
|
||||||
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
"@img/sharp-linux-x64": "0.35.0",
|
||||||
"@img/sharp-linuxmusl-x64": "0.34.5",
|
"@img/sharp-linuxmusl-arm64": "0.35.0",
|
||||||
"@img/sharp-wasm32": "0.34.5",
|
"@img/sharp-linuxmusl-x64": "0.35.0",
|
||||||
"@img/sharp-win32-arm64": "0.34.5",
|
"@img/sharp-webcontainers-wasm32": "0.35.0",
|
||||||
"@img/sharp-win32-ia32": "0.34.5",
|
"@img/sharp-win32-arm64": "0.35.0",
|
||||||
"@img/sharp-win32-x64": "0.34.5"
|
"@img/sharp-win32-ia32": "0.35.0",
|
||||||
|
"@img/sharp-win32-x64": "0.35.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/simple-code-frame": {
|
"node_modules/simple-code-frame": {
|
||||||
|
|||||||
+8
-2
@@ -15,6 +15,11 @@
|
|||||||
"domains:sync": "node scripts/sync-global-domains.mjs",
|
"domains:sync": "node scripts/sync-global-domains.mjs",
|
||||||
"i18n": "node scripts/i18n-validate.cjs",
|
"i18n": "node scripts/i18n-validate.cjs",
|
||||||
"i18n:validate": "node scripts/i18n-validate.cjs",
|
"i18n:validate": "node scripts/i18n-validate.cjs",
|
||||||
|
"test:config-compatibility": "tsx --test scripts/config-compatibility.test.ts",
|
||||||
|
"test:web-crypto": "tsx --test scripts/web-crypto-availability.test.ts",
|
||||||
|
"test:webauthn-mobile": "node --test scripts/webauthn-mobile-connector.test.mjs",
|
||||||
|
"test:webauthn-connector": "node --test scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts",
|
||||||
|
"test:webauthn-connectors": "node --test scripts/webauthn-mobile-connector.test.mjs scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts",
|
||||||
"deploy": "wrangler deploy",
|
"deploy": "wrangler deploy",
|
||||||
"deploy:kv": "node scripts/ensure-kv.cjs && wrangler deploy -c wrangler.kv.toml",
|
"deploy:kv": "node scripts/ensure-kv.cjs && wrangler deploy -c wrangler.kv.toml",
|
||||||
"deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo"
|
"deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo"
|
||||||
@@ -46,7 +51,8 @@
|
|||||||
"undici": ">=7.28.0",
|
"undici": ">=7.28.0",
|
||||||
"@babel/core": ">=7.29.6",
|
"@babel/core": ">=7.29.6",
|
||||||
"esbuild": ">=0.28.1",
|
"esbuild": ">=0.28.1",
|
||||||
"ws": "8.21.0"
|
"ws": "8.21.0",
|
||||||
|
"sharp": "0.35.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/workers-types": "^4.20260630.1",
|
"@cloudflare/workers-types": "^4.20260630.1",
|
||||||
@@ -54,7 +60,7 @@
|
|||||||
"@types/node": "^26.0.1",
|
"@types/node": "^26.0.1",
|
||||||
"autoprefixer": "^10.5.2",
|
"autoprefixer": "^10.5.2",
|
||||||
"opencc-js": "^1.3.2",
|
"opencc-js": "^1.3.2",
|
||||||
"postcss": "^8.5.16",
|
"postcss": "^8.5.23",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"tsx": "^4.22.4",
|
"tsx": "^4.22.4",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
|
|||||||
@@ -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,53 @@
|
|||||||
|
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,
|
||||||
|
suppressOnboardingInterstitials: 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -156,7 +156,9 @@
|
|||||||
compatibility: {
|
compatibility: {
|
||||||
// Single source of truth for /config.version and /api/version.
|
// Single source of truth for /config.version and /api/version.
|
||||||
// /config.version 与 /api/version 的统一版本号来源。
|
// /config.version 与 /api/version 的统一版本号来源。
|
||||||
bitwardenServerVersion: '2026.4.1',
|
// Vaultwarden 1.37.0 advertises 2026.6.0 after aligning its API response
|
||||||
|
// with the response contract required by Bitwarden 2026.7.x clients.
|
||||||
|
bitwardenServerVersion: '2026.6.0',
|
||||||
// Official 2026.4.x clients need this flag to receive and use cipher.key.
|
// Official 2026.4.x clients need this flag to receive and use cipher.key.
|
||||||
// Hiding existing item keys makes item-key encrypted vault data unreadable.
|
// Hiding existing item keys makes item-key encrypted vault data unreadable.
|
||||||
// 官方 2026.4.x 客户端需要该开关来接收并使用 cipher.key。
|
// 官方 2026.4.x 客户端需要该开关来接收并使用 cipher.key。
|
||||||
|
|||||||
@@ -1247,20 +1247,15 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env:
|
|||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(request.url);
|
// Read the request body only. Accepting these fields from the query string
|
||||||
let input: { blobName?: unknown; masterPasswordHash?: unknown } = {};
|
// would put the master-password authentication hash in the URL, where it is
|
||||||
if (request.method === 'POST') {
|
// captured by request logs, browser history and Referer headers.
|
||||||
|
let input: { blobName?: unknown; masterPasswordHash?: unknown };
|
||||||
try {
|
try {
|
||||||
input = await request.json<{ blobName?: unknown; masterPasswordHash?: unknown }>();
|
input = await request.json<{ blobName?: unknown; masterPasswordHash?: unknown }>();
|
||||||
} catch {
|
} catch {
|
||||||
return errorResponse('Backup attachment download payload is invalid', 400);
|
return errorResponse('Backup attachment download payload is invalid', 400);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
input = {
|
|
||||||
blobName: url.searchParams.get('blobName') || '',
|
|
||||||
masterPasswordHash: url.searchParams.get('masterPasswordHash') || '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const verificationError = await requireBackupUserVerification(
|
const verificationError = await requireBackupUserVerification(
|
||||||
actorUser,
|
actorUser,
|
||||||
|
|||||||
@@ -206,6 +206,16 @@ function buildPreloginResponse(
|
|||||||
kdfIterations,
|
kdfIterations,
|
||||||
kdfMemory,
|
kdfMemory,
|
||||||
kdfParallelism,
|
kdfParallelism,
|
||||||
|
// Current official servers expose the consolidated KDF model alongside
|
||||||
|
// the legacy flat fields. Keep both shapes while clients migrate.
|
||||||
|
kdfSettings: {
|
||||||
|
kdfType,
|
||||||
|
iterations: kdfIterations,
|
||||||
|
memory: kdfMemory,
|
||||||
|
parallelism: kdfParallelism,
|
||||||
|
},
|
||||||
|
salt: null,
|
||||||
|
// Preserve the historic NodeWarden aliases for older integrations.
|
||||||
KdfSettings: {
|
KdfSettings: {
|
||||||
KdfType: kdfType,
|
KdfType: kdfType,
|
||||||
Iterations: kdfIterations,
|
Iterations: kdfIterations,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
handleRunAdminConfiguredBackup,
|
handleRunAdminConfiguredBackup,
|
||||||
handleUpdateAdminBackupSettings,
|
handleUpdateAdminBackupSettings,
|
||||||
} from './handlers/backup';
|
} from './handlers/backup';
|
||||||
|
import { errorResponse } from './utils/response';
|
||||||
|
|
||||||
export async function handleAdminBackupRoute(
|
export async function handleAdminBackupRoute(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -26,9 +27,23 @@ export async function handleAdminBackupRoute(
|
|||||||
return handleAdminExportBackup(request, env, actorUser);
|
return handleAdminExportBackup(request, env, actorUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/admin/backup/blob' && (method === 'GET' || method === 'POST')) {
|
if (path === '/api/admin/backup/blob') {
|
||||||
|
// POST only: this endpoint requires master-password verification, and a GET
|
||||||
|
// could only carry that credential in the query string, where it would leak
|
||||||
|
// into request logs, proxy logs, browser history and Referer headers.
|
||||||
|
// The credential is the same value clients send to /identity/connect/token,
|
||||||
|
// so a leaked copy is enough to sign in as this admin.
|
||||||
|
if (method === 'POST') {
|
||||||
return handleDownloadAdminBackupAttachment(request, env, actorUser);
|
return handleDownloadAdminBackupAttachment(request, env, actorUser);
|
||||||
}
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
return errorResponse(
|
||||||
|
'Use POST with a JSON body for this endpoint. Credentials must not be sent in the URL.',
|
||||||
|
405
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (path === '/api/admin/backup/settings') {
|
if (path === '/api/admin/backup/settings') {
|
||||||
if (method === 'GET') return handleGetAdminBackupSettings(request, env, actorUser);
|
if (method === 'GET') return handleGetAdminBackupSettings(request, env, actorUser);
|
||||||
|
|||||||
+1
-54
@@ -36,6 +36,7 @@ import { jsonResponse, unsupportedResponse } from './utils/response';
|
|||||||
import { StorageService } from './services/storage';
|
import { StorageService } from './services/storage';
|
||||||
import type { Env } from './types';
|
import type { Env } from './types';
|
||||||
import { getConfiguredWebAuthnAllowedOrigins } from './utils/origins';
|
import { getConfiguredWebAuthnAllowedOrigins } from './utils/origins';
|
||||||
|
import { buildConfigResponse } from './config-response';
|
||||||
|
|
||||||
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
|
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
|
||||||
type JwtUnsafeReason = 'missing' | 'too_short' | null;
|
type JwtUnsafeReason = 'missing' | 'too_short' | null;
|
||||||
@@ -95,60 +96,6 @@ function handleMissingWebsiteIcon(): Response {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildIconServiceBase(origin: string): string {
|
|
||||||
return `${origin}/icons`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildIconServiceTemplate(origin: string): string {
|
|
||||||
return `${buildIconServiceBase(origin)}/{}/icon.png`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildIconServiceCsp(origin: string): string {
|
|
||||||
return `img-src 'self' data: ${origin}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildConfigResponse(origin: string) {
|
|
||||||
const fillAssistBase = `${origin}/fill-assist/`;
|
|
||||||
return {
|
|
||||||
version: LIMITS.compatibility.bitwardenServerVersion,
|
|
||||||
gitHash: 'nodewarden',
|
|
||||||
server: null,
|
|
||||||
environment: {
|
|
||||||
cloudRegion: 'self-hosted',
|
|
||||||
vault: origin,
|
|
||||||
api: origin + '/api',
|
|
||||||
identity: origin + '/identity',
|
|
||||||
notifications: origin + '/notifications',
|
|
||||||
icons: origin,
|
|
||||||
sso: '',
|
|
||||||
fillAssistRules: fillAssistBase,
|
|
||||||
},
|
|
||||||
push: {
|
|
||||||
pushTechnology: 0,
|
|
||||||
vapidPublicKey: null,
|
|
||||||
},
|
|
||||||
communication: null,
|
|
||||||
settings: {
|
|
||||||
disableUserRegistration: false,
|
|
||||||
},
|
|
||||||
_icon_service_url: buildIconServiceTemplate(origin),
|
|
||||||
_icon_service_csp: buildIconServiceCsp(origin),
|
|
||||||
featureStates: {
|
|
||||||
'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled,
|
|
||||||
'duo-redirect': true,
|
|
||||||
'email-verification': true,
|
|
||||||
'fill-assist-targeting-rules': true,
|
|
||||||
'pm-19051-send-email-verification': false,
|
|
||||||
'pm-19148-innovation-archive': true,
|
|
||||||
'pm-4516-devices-add-last-activity-date': true,
|
|
||||||
'pm-30529-webauthn-related-origins': true,
|
|
||||||
'unauth-ui-refresh': true,
|
|
||||||
'web-push': false,
|
|
||||||
},
|
|
||||||
object: 'config',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeIconHost(rawHost: string): string | null {
|
function normalizeIconHost(rawHost: string): string | null {
|
||||||
let decoded: string;
|
let decoded: string;
|
||||||
try {
|
try {
|
||||||
|
|||||||
+18
-1
@@ -8,6 +8,13 @@ export const OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS = [
|
|||||||
'chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn',
|
'chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
// Bitwarden desktop is migrating from file:// to this privileged Electron
|
||||||
|
// origin. Official clients keep the legacy file:// path as a compatibility
|
||||||
|
// fallback while self-hosted servers add CORS support for the new origin.
|
||||||
|
export const OFFICIAL_BITWARDEN_DESKTOP_ORIGINS = [
|
||||||
|
'bw-desktop-file://bundle',
|
||||||
|
] as const;
|
||||||
|
|
||||||
export function normalizeOrigin(value: unknown): string | null {
|
export function normalizeOrigin(value: unknown): string | null {
|
||||||
const raw = String(value || '').trim();
|
const raw = String(value || '').trim();
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
@@ -30,10 +37,20 @@ export function isBrowserExtensionOrigin(origin: unknown): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isOfficialBitwardenDesktopOrigin(origin: unknown): boolean {
|
||||||
|
const normalized = normalizeOrigin(origin);
|
||||||
|
return !!normalized && OFFICIAL_BITWARDEN_DESKTOP_ORIGINS.includes(
|
||||||
|
normalized as (typeof OFFICIAL_BITWARDEN_DESKTOP_ORIGINS)[number]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getConfiguredWebAuthnAllowedOrigins(
|
export function getConfiguredWebAuthnAllowedOrigins(
|
||||||
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>
|
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>
|
||||||
): string[] {
|
): string[] {
|
||||||
const seen = new Set<string>(OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS);
|
const seen = new Set<string>([
|
||||||
|
...OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS,
|
||||||
|
...OFFICIAL_BITWARDEN_DESKTOP_ORIGINS,
|
||||||
|
]);
|
||||||
for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) {
|
for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) {
|
||||||
const origin = normalizeOrigin(item);
|
const origin = normalizeOrigin(item);
|
||||||
if (origin) seen.add(origin);
|
if (origin) seen.add(origin);
|
||||||
|
|||||||
+19
-3
@@ -3,6 +3,7 @@ import type { Env } from '../types';
|
|||||||
import {
|
import {
|
||||||
isBrowserExtensionOrigin,
|
isBrowserExtensionOrigin,
|
||||||
isConfiguredWebAuthnAllowedOrigin,
|
isConfiguredWebAuthnAllowedOrigin,
|
||||||
|
isOfficialBitwardenDesktopOrigin,
|
||||||
normalizeOrigin,
|
normalizeOrigin,
|
||||||
} from './origins';
|
} from './origins';
|
||||||
|
|
||||||
@@ -48,7 +49,10 @@ function getCorsPolicy(request: Request, env: Env): { allowOrigin: string | null
|
|||||||
if (origin === url.origin) {
|
if (origin === url.origin) {
|
||||||
return { allowOrigin: origin, allowCredentials: true };
|
return { allowOrigin: origin, allowCredentials: true };
|
||||||
}
|
}
|
||||||
if (isBrowserExtensionOrigin(origin) && isConfiguredWebAuthnAllowedOrigin(env, origin)) {
|
if (
|
||||||
|
(isBrowserExtensionOrigin(origin) || isOfficialBitwardenDesktopOrigin(origin))
|
||||||
|
&& isConfiguredWebAuthnAllowedOrigin(env, origin)
|
||||||
|
) {
|
||||||
return { allowOrigin: origin, allowCredentials: true };
|
return { allowOrigin: origin, allowCredentials: true };
|
||||||
}
|
}
|
||||||
if (isWildcardCorsPath(url.pathname)) {
|
if (isWildcardCorsPath(url.pathname)) {
|
||||||
@@ -100,10 +104,22 @@ export function applyCors(
|
|||||||
headers.set(k, v);
|
headers.set(k, v);
|
||||||
}
|
}
|
||||||
// Security headers applied to every response.
|
// Security headers applied to every response.
|
||||||
headers.set('X-Frame-Options', 'DENY');
|
|
||||||
headers.set('X-Content-Type-Options', 'nosniff');
|
headers.set('X-Content-Type-Options', 'nosniff');
|
||||||
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
if (!headers.has('Content-Security-Policy')) {
|
const isWebAuthnFrameConnector = new URL(request.url).pathname === '/webauthn-connector.html';
|
||||||
|
if (isWebAuthnFrameConnector) {
|
||||||
|
// Official desktop and browser clients render this exact endpoint inside a
|
||||||
|
// 40px cross-origin iframe. The connector validates its parent before any
|
||||||
|
// WebAuthn request or postMessage, so only this protocol page may be framed.
|
||||||
|
headers.delete('X-Frame-Options');
|
||||||
|
headers.set(
|
||||||
|
'Content-Security-Policy',
|
||||||
|
"default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
headers.set('X-Frame-Options', 'DENY');
|
||||||
|
}
|
||||||
|
if (!isWebAuthnFrameConnector && !headers.has('Content-Security-Policy')) {
|
||||||
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:");
|
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:");
|
||||||
}
|
}
|
||||||
return new Response(response.body, {
|
return new Response(response.body, {
|
||||||
|
|||||||
@@ -17,8 +17,12 @@ export function buildAccountKeys(user: Pick<User, 'privateKey' | 'publicKey'>):
|
|||||||
wrappedPrivateKey: user.privateKey,
|
wrappedPrivateKey: user.privateKey,
|
||||||
publicKey,
|
publicKey,
|
||||||
signedPublicKey: null,
|
signedPublicKey: null,
|
||||||
|
object: 'publicKeyEncryptionKeyPair',
|
||||||
Object: 'publicKeyEncryptionKeyPair',
|
Object: 'publicKeyEncryptionKeyPair',
|
||||||
},
|
},
|
||||||
|
securityState: null,
|
||||||
|
signatureKeyPair: null,
|
||||||
|
object: 'privateKeys',
|
||||||
Object: 'privateKeys',
|
Object: 'privateKeys',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,158 +1,77 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import { Check, Copy, Minus, Plus, RefreshCw, ShieldCheck } from 'lucide-preact';
|
import { Check, Copy, Download, LoaderCircle, Minus, Plus, RefreshCw, ShieldCheck } from 'lucide-preact';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import { EFFLongWordList } from '@/lib/eff-word-list';
|
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
import {
|
||||||
|
clampInteger,
|
||||||
|
defaultGeneratorSettings,
|
||||||
|
estimateStrength,
|
||||||
|
generateValue,
|
||||||
|
normalizeGeneratorSettings,
|
||||||
|
type EmailMode,
|
||||||
|
type EmailOptions,
|
||||||
|
type GeneratorMode,
|
||||||
|
type GeneratorSettings,
|
||||||
|
type PassphraseOptions,
|
||||||
|
type PasswordOptions,
|
||||||
|
type PinOptions,
|
||||||
|
type SshKeyOptions,
|
||||||
|
type UsernameOptions,
|
||||||
|
} from '@/lib/password-generator';
|
||||||
|
import { generateSshKey, type GeneratedSshKey } from '@/lib/ssh-key-generator';
|
||||||
|
|
||||||
type GeneratorMode = 'password' | 'passphrase';
|
const SETTINGS_KEY = 'nodewarden.passwordGenerator.settings.v2';
|
||||||
|
|
||||||
interface PasswordOptions {
|
function readSettings(): GeneratorSettings {
|
||||||
length: number;
|
|
||||||
uppercase: boolean;
|
|
||||||
lowercase: boolean;
|
|
||||||
numbers: boolean;
|
|
||||||
special: boolean;
|
|
||||||
minNumbers: number;
|
|
||||||
minSpecial: number;
|
|
||||||
avoidAmbiguous: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PassphraseOptions {
|
|
||||||
words: number;
|
|
||||||
separator: string;
|
|
||||||
capitalize: boolean;
|
|
||||||
includeNumber: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SETTINGS_KEY = 'nodewarden.passwordGenerator.settings.v1';
|
|
||||||
const UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
||||||
const LOWERCASE = 'abcdefghijklmnopqrstuvwxyz';
|
|
||||||
const DIGITS = '0123456789';
|
|
||||||
const SPECIAL = '!@#$%^&*';
|
|
||||||
const AMBIGUOUS = new Set(['I', 'L', 'O', 'l', 'o', '0', '1']);
|
|
||||||
|
|
||||||
const defaultPasswordOptions: PasswordOptions = {
|
|
||||||
length: 14,
|
|
||||||
uppercase: true,
|
|
||||||
lowercase: true,
|
|
||||||
numbers: true,
|
|
||||||
special: false,
|
|
||||||
minNumbers: 1,
|
|
||||||
minSpecial: 1,
|
|
||||||
avoidAmbiguous: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultPassphraseOptions: PassphraseOptions = {
|
|
||||||
words: 6,
|
|
||||||
separator: '-',
|
|
||||||
capitalize: false,
|
|
||||||
includeNumber: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
function clamp(value: unknown, minimum: number, maximum: number, fallback: number): number {
|
|
||||||
const parsed = Number(value);
|
|
||||||
return Number.isFinite(parsed) ? Math.min(maximum, Math.max(minimum, Math.round(parsed))) : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSettings(): { mode: GeneratorMode; password: PasswordOptions; passphrase: PassphraseOptions } {
|
|
||||||
try {
|
try {
|
||||||
const stored = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') as Partial<{ mode: GeneratorMode; password: Partial<PasswordOptions>; passphrase: Partial<PassphraseOptions> }>;
|
const current = localStorage.getItem(SETTINGS_KEY);
|
||||||
return {
|
if (current) return normalizeGeneratorSettings(JSON.parse(current));
|
||||||
mode: stored.mode === 'passphrase' ? 'passphrase' : 'password',
|
|
||||||
password: {
|
// Preserve compatible options for users upgrading from the original generator.
|
||||||
...defaultPasswordOptions,
|
const legacy = JSON.parse(localStorage.getItem('nodewarden.passwordGenerator.settings.v1') || '{}');
|
||||||
...stored.password,
|
return normalizeGeneratorSettings(legacy);
|
||||||
length: clamp(stored.password?.length, 5, 128, defaultPasswordOptions.length),
|
|
||||||
minNumbers: clamp(stored.password?.minNumbers, 0, 9, defaultPasswordOptions.minNumbers),
|
|
||||||
minSpecial: clamp(stored.password?.minSpecial, 0, 9, defaultPasswordOptions.minSpecial),
|
|
||||||
},
|
|
||||||
passphrase: {
|
|
||||||
...defaultPassphraseOptions,
|
|
||||||
...stored.passphrase,
|
|
||||||
words: clamp(stored.passphrase?.words, 3, 20, defaultPassphraseOptions.words),
|
|
||||||
separator: String(stored.passphrase?.separator ?? defaultPassphraseOptions.separator).slice(0, 1),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch {
|
} catch {
|
||||||
return { mode: 'password', password: defaultPasswordOptions, passphrase: defaultPassphraseOptions };
|
return defaultGeneratorSettings;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function randomIndex(length: number): number {
|
|
||||||
const range = 0x1_0000_0000;
|
|
||||||
const upperBound = Math.floor(range / length) * length;
|
|
||||||
const buffer = new Uint32Array(1);
|
|
||||||
do crypto.getRandomValues(buffer); while (buffer[0] >= upperBound);
|
|
||||||
return buffer[0] % length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pick(characters: string): string {
|
|
||||||
return characters[randomIndex(characters.length)];
|
|
||||||
}
|
|
||||||
|
|
||||||
function shuffle(value: string[]): string[] {
|
|
||||||
for (let index = value.length - 1; index > 0; index -= 1) {
|
|
||||||
const next = randomIndex(index + 1);
|
|
||||||
[value[index], value[next]] = [value[next], value[index]];
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function filtered(characters: string, avoidAmbiguous: boolean): string {
|
|
||||||
return avoidAmbiguous ? characters.split('').filter((character) => !AMBIGUOUS.has(character)).join('') : characters;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generatePassword(options: PasswordOptions): string {
|
|
||||||
const sets: Array<{ chars: string; minimum: number }> = [];
|
|
||||||
if (options.uppercase) sets.push({ chars: filtered(UPPERCASE, options.avoidAmbiguous), minimum: 1 });
|
|
||||||
if (options.lowercase) sets.push({ chars: filtered(LOWERCASE, options.avoidAmbiguous), minimum: 1 });
|
|
||||||
if (options.numbers) sets.push({ chars: filtered(DIGITS, options.avoidAmbiguous), minimum: options.minNumbers });
|
|
||||||
if (options.special) sets.push({ chars: SPECIAL, minimum: options.minSpecial });
|
|
||||||
if (!sets.length) sets.push({ chars: filtered(LOWERCASE, options.avoidAmbiguous), minimum: 1 });
|
|
||||||
|
|
||||||
const minimumLength = sets.reduce((total, set) => total + set.minimum, 0);
|
|
||||||
const length = Math.max(options.length, minimumLength, 5);
|
|
||||||
const allCharacters = sets.map((set) => set.chars).join('');
|
|
||||||
const characters = sets.flatMap((set) => Array.from({ length: set.minimum }, () => pick(set.chars)));
|
|
||||||
while (characters.length < length) characters.push(pick(allCharacters));
|
|
||||||
return shuffle(characters).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function generatePassphrase(options: PassphraseOptions): string {
|
|
||||||
const words = Array.from({ length: options.words }, () => EFFLongWordList[randomIndex(EFFLongWordList.length)]);
|
|
||||||
if (options.capitalize) {
|
|
||||||
for (let index = 0; index < words.length; index += 1) words[index] = words[index][0].toUpperCase() + words[index].slice(1);
|
|
||||||
}
|
|
||||||
if (options.includeNumber) words[randomIndex(words.length)] += String(randomIndex(10));
|
|
||||||
return words.join(options.separator);
|
|
||||||
}
|
|
||||||
|
|
||||||
function strengthLabel(mode: GeneratorMode, value: string): { label: string; score: number } {
|
|
||||||
const score = mode === 'password' ? Math.min(4, Math.max(1, Math.floor(value.length / 5))) : Math.min(4, Math.max(1, Math.floor(value.split(/[-_. ]/).filter(Boolean).length / 2)));
|
|
||||||
return { score, label: t(['txt_password_strength_weak', 'txt_password_strength_fair', 'txt_password_strength_good', 'txt_password_strength_strong'][score - 1]) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PasswordGeneratorPage() {
|
export default function PasswordGeneratorPage() {
|
||||||
const initial = useMemo(readSettings, []);
|
const initial = useMemo(readSettings, []);
|
||||||
const [mode, setMode] = useState<GeneratorMode>(initial.mode);
|
const [settings, setSettings] = useState<GeneratorSettings>(initial);
|
||||||
const [passwordOptions, setPasswordOptions] = useState<PasswordOptions>(initial.password);
|
|
||||||
const [passphraseOptions, setPassphraseOptions] = useState<PassphraseOptions>(initial.passphrase);
|
|
||||||
const [seed, setSeed] = useState(0);
|
const [seed, setSeed] = useState(0);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [sshKey, setSshKey] = useState<GeneratedSshKey | null>(null);
|
||||||
|
const [sshKeyError, setSshKeyError] = useState('');
|
||||||
|
const [sshKeyLoading, setSshKeyLoading] = useState(false);
|
||||||
|
|
||||||
const generated = useMemo(
|
const generated = useMemo(() => settings.mode === 'sshKey' ? sshKey?.fingerprint || '' : generateValue(settings), [settings, seed, sshKey]);
|
||||||
() => (mode === 'password' ? generatePassword(passwordOptions) : generatePassphrase(passphraseOptions)),
|
const strength = useMemo(
|
||||||
[mode, passwordOptions, passphraseOptions, seed]
|
() => estimateStrength(settings.mode, generated, settings.mode === 'passphrase' ? settings.passphrase.words : undefined),
|
||||||
|
[generated, settings.mode, settings.passphrase.words],
|
||||||
);
|
);
|
||||||
const strength = useMemo(() => strengthLabel(mode, generated), [generated, mode]);
|
const strengthLabel = strength
|
||||||
|
? t(['txt_password_strength_weak', 'txt_password_strength_fair', 'txt_password_strength_good', 'txt_password_strength_strong'][strength - 1])
|
||||||
|
: '';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ mode, password: passwordOptions, passphrase: passphraseOptions }));
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||||
} catch {
|
} catch {
|
||||||
// The generator remains fully usable when browser storage is unavailable.
|
// The generator remains fully usable when browser storage is unavailable.
|
||||||
}
|
}
|
||||||
}, [mode, passwordOptions, passphraseOptions]);
|
}, [settings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings.mode !== 'sshKey') return;
|
||||||
|
let cancelled = false;
|
||||||
|
setSshKeyLoading(true);
|
||||||
|
setSshKeyError('');
|
||||||
|
void generateSshKey({ ...settings.sshKey, comment: '' })
|
||||||
|
.then((value) => { if (!cancelled) setSshKey(value); })
|
||||||
|
.catch(() => { if (!cancelled) { setSshKey(null); setSshKeyError(t('txt_generator_ssh_error')); } })
|
||||||
|
.finally(() => { if (!cancelled) setSshKeyLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [settings.mode, settings.sshKey.type, settings.sshKey.rsaLength, seed]);
|
||||||
|
|
||||||
const regenerate = () => {
|
const regenerate = () => {
|
||||||
setCopied(false);
|
setCopied(false);
|
||||||
@@ -160,17 +79,49 @@ export default function PasswordGeneratorPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const copy = async () => {
|
const copy = async () => {
|
||||||
await copyTextToClipboard(generated, { onSuccess: () => setCopied(true), onError: () => setCopied(false) });
|
const value = settings.mode === 'sshKey' && sshKey ? publicKeyWithComment(sshKey.publicKey, settings.sshKey.comment) : generated;
|
||||||
|
await copyTextToClipboard(value, { onSuccess: () => setCopied(true), onError: () => setCopied(false) });
|
||||||
window.setTimeout(() => setCopied(false), 1600);
|
window.setTimeout(() => setCopied(false), 1600);
|
||||||
};
|
};
|
||||||
|
|
||||||
const changePasswordOption = <K extends keyof PasswordOptions>(key: K, value: PasswordOptions[K]) => {
|
const changeMode = (mode: GeneratorMode) => {
|
||||||
setPasswordOptions((current) => ({ ...current, [key]: value }));
|
setSettings((current) => ({ ...current, mode }));
|
||||||
setCopied(false);
|
setCopied(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const changePasswordOption = <K extends keyof PasswordOptions>(key: K, value: PasswordOptions[K]) => {
|
||||||
|
setSettings((current) => ({ ...current, password: { ...current.password, [key]: value } }));
|
||||||
|
setCopied(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeCharacterType = (key: 'uppercase' | 'lowercase' | 'numbers' | 'special', checked: boolean) => {
|
||||||
|
const enabled = ['uppercase', 'lowercase', 'numbers', 'special'].filter((item) => settings.password[item as 'uppercase']);
|
||||||
|
if (!checked && enabled.length === 1 && enabled[0] === key) return;
|
||||||
|
changePasswordOption(key, checked);
|
||||||
|
};
|
||||||
|
|
||||||
const changePassphraseOption = <K extends keyof PassphraseOptions>(key: K, value: PassphraseOptions[K]) => {
|
const changePassphraseOption = <K extends keyof PassphraseOptions>(key: K, value: PassphraseOptions[K]) => {
|
||||||
setPassphraseOptions((current) => ({ ...current, [key]: value }));
|
setSettings((current) => ({ ...current, passphrase: { ...current.passphrase, [key]: value } }));
|
||||||
|
setCopied(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePinOption = <K extends keyof PinOptions>(key: K, value: PinOptions[K]) => {
|
||||||
|
setSettings((current) => ({ ...current, pin: { ...current.pin, [key]: value } }));
|
||||||
|
setCopied(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeUsernameOption = <K extends keyof UsernameOptions>(key: K, value: UsernameOptions[K]) => {
|
||||||
|
setSettings((current) => ({ ...current, username: { ...current.username, [key]: value } }));
|
||||||
|
setCopied(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeEmailOption = <K extends keyof EmailOptions>(key: K, value: EmailOptions[K]) => {
|
||||||
|
setSettings((current) => ({ ...current, email: { ...current.email, [key]: value } }));
|
||||||
|
setCopied(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeSshKeyOption = <K extends keyof SshKeyOptions>(key: K, value: SshKeyOptions[K]) => {
|
||||||
|
setSettings((current) => ({ ...current, sshKey: { ...current.sshKey, [key]: value } }));
|
||||||
setCopied(false);
|
setCopied(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -178,62 +129,182 @@ export default function PasswordGeneratorPage() {
|
|||||||
<section className="generator-page" aria-label={t('txt_password_generator')}>
|
<section className="generator-page" aria-label={t('txt_password_generator')}>
|
||||||
<div className="generator-layout">
|
<div className="generator-layout">
|
||||||
<section className="generator-output-card" aria-live="polite">
|
<section className="generator-output-card" aria-live="polite">
|
||||||
<div className="settings-category-tabs" role="tablist" aria-label={t('txt_generator_type')}>
|
<div className="settings-category-tabs generator-mode-tabs" role="tablist" aria-label={t('txt_generator_type')}>
|
||||||
<button type="button" role="tab" aria-selected={mode === 'password'} className={`settings-category-tab ${mode === 'password' ? 'active' : ''}`} onClick={() => setMode('password')}>{t('txt_password')}</button>
|
{([
|
||||||
<button type="button" role="tab" aria-selected={mode === 'passphrase'} className={`settings-category-tab ${mode === 'passphrase' ? 'active' : ''}`} onClick={() => setMode('passphrase')}>{t('txt_passphrase')}</button>
|
['password', 'txt_password'],
|
||||||
|
['passphrase', 'txt_passphrase'],
|
||||||
|
['pin', 'txt_generator_pin'],
|
||||||
|
['username', 'txt_generator_username'],
|
||||||
|
['email', 'txt_generator_email_alias'],
|
||||||
|
['sshKey', 'txt_generator_ssh_key'],
|
||||||
|
] as const).map(([mode, label]) => (
|
||||||
|
<button key={mode} type="button" role="tab" aria-selected={settings.mode === mode} className={`settings-category-tab ${settings.mode === mode ? 'active' : ''}`} onClick={() => changeMode(mode)}>{t(label)}</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<output className="generator-value" aria-label={t('txt_generated_password')}>{generated}</output>
|
{settings.mode === 'sshKey' ? (
|
||||||
<div className="generator-strength-row">
|
<SshKeyOutput value={sshKey} loading={sshKeyLoading} error={sshKeyError} comment={settings.sshKey.comment} />
|
||||||
<div className="generator-strength" aria-label={`${t('txt_password_strength')}: ${strength.label}`}>
|
) : <output className={`generator-value ${generated ? '' : 'empty'}`} aria-label={t('txt_generated_value')}>{generated || t('txt_generator_email_required_hint')}</output>}
|
||||||
{[1, 2, 3, 4].map((level) => <span key={level} className={level <= strength.score ? `active level-${strength.score}` : ''} />)}
|
{settings.mode !== 'sshKey' && <div className="generator-meta-row">
|
||||||
</div>
|
{strength > 0 ? (
|
||||||
<span><ShieldCheck size={15} /> {strength.label}</span>
|
<>
|
||||||
|
<div className="generator-strength" aria-label={`${t('txt_password_strength')}: ${strengthLabel}`}>
|
||||||
|
{[1, 2, 3, 4].map((level) => <span key={level} className={level <= strength ? `active level-${strength}` : ''} />)}
|
||||||
</div>
|
</div>
|
||||||
|
<span><ShieldCheck size={15} /> {strengthLabel}</span>
|
||||||
|
</>
|
||||||
|
) : <span />}
|
||||||
|
<span>{t('txt_generator_character_count', { count: generated.length })}</span>
|
||||||
|
</div>}
|
||||||
<div className="actions generator-actions">
|
<div className="actions generator-actions">
|
||||||
<button type="button" className="btn btn-primary" onClick={regenerate}><RefreshCw size={16} className="btn-icon" />{t('txt_regenerate')}</button>
|
<button type="button" className="btn btn-primary" disabled={sshKeyLoading} onClick={regenerate}>{sshKeyLoading ? <LoaderCircle size={16} className="btn-icon generator-spinner" /> : <RefreshCw size={16} className="btn-icon" />}{t('txt_regenerate')}</button>
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => void copy()}><Copy size={16} className="btn-icon" />{copied ? t('txt_copied') : t('txt_copy')}</button>
|
<button type="button" className="btn btn-secondary" disabled={(settings.mode === 'sshKey' && !sshKey) || !generated} onClick={() => void copy()}><Copy size={16} className="btn-icon" />{copied ? t('txt_copied') : settings.mode === 'sshKey' ? t('txt_generator_copy_public_key') : t('txt_copy')}</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="generator-security-note"><Check size={15} />{t('txt_generator_security_note')}</p>
|
<p className="generator-security-note"><Check size={15} />{t(settings.mode === 'sshKey' ? 'txt_generator_ssh_security_note' : 'txt_generator_security_note')}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="generator-options-card" aria-labelledby="generator-options-title">
|
<section className="generator-options-card" aria-labelledby="generator-options-title">
|
||||||
<h2 id="generator-options-title">{t('txt_options')}</h2>
|
<h2 id="generator-options-title">{t('txt_options')}</h2>
|
||||||
{mode === 'password' ? (
|
{settings.mode === 'password' && (
|
||||||
|
<PasswordOptionFields options={settings.password} onChange={changePasswordOption} onCharacterTypeChange={changeCharacterType} />
|
||||||
|
)}
|
||||||
|
{settings.mode === 'passphrase' && (
|
||||||
<>
|
<>
|
||||||
<GeneratorNumberStepper id="length" label={t('txt_generator_length')} value={passwordOptions.length} minimum={5} maximum={128} fallback={14} onChange={(value) => changePasswordOption('length', value)} />
|
<GeneratorNumberStepper id="words" label={t('txt_generator_words')} value={settings.passphrase.words} minimum={3} maximum={20} fallback={6} onChange={(value) => changePassphraseOption('words', value)} />
|
||||||
<fieldset className="generator-option-group"><legend>{t('txt_generator_character_types')}</legend>
|
<label className="generator-select-field" htmlFor="generator-word-list"><span>{t('txt_generator_word_list')}</span><select id="generator-word-list" className="input" value={settings.passphrase.wordList} onChange={(event) => changePassphraseOption('wordList', event.currentTarget.value as 'eff' | 'custom')}><option value="eff">{t('txt_generator_eff_word_list')}</option><option value="custom">{t('txt_generator_custom_word_list')}</option></select></label>
|
||||||
<GeneratorToggle checked={passwordOptions.uppercase} onChange={(checked) => changePasswordOption('uppercase', checked)} label={t('txt_generator_uppercase')} />
|
{settings.passphrase.wordList === 'custom' && <label className="generator-text-field" htmlFor="generator-custom-words"><span>{t('txt_generator_custom_words')}</span><textarea id="generator-custom-words" className="input generator-word-list-input" rows={6} spellcheck={false} placeholder={t('txt_generator_custom_words_placeholder')} value={settings.passphrase.customWords} onInput={(event) => changePassphraseOption('customWords', event.currentTarget.value)} /></label>}
|
||||||
<GeneratorToggle checked={passwordOptions.lowercase} onChange={(checked) => changePasswordOption('lowercase', checked)} label={t('txt_generator_lowercase')} />
|
<label className="generator-number-field" htmlFor="generator-separator"><span>{t('txt_generator_separator')}</span><input id="generator-separator" className="input" type="text" maxLength={1} value={settings.passphrase.separator} onInput={(event) => changePassphraseOption('separator', event.currentTarget.value.slice(0, 1))} /></label>
|
||||||
<GeneratorToggle checked={passwordOptions.numbers} onChange={(checked) => changePasswordOption('numbers', checked)} label={t('txt_generator_numbers')} />
|
|
||||||
{passwordOptions.numbers && <GeneratorNumberStepper id="min-numbers" compact label={t('txt_generator_minimum')} value={passwordOptions.minNumbers} minimum={0} maximum={9} fallback={1} onChange={(value) => changePasswordOption('minNumbers', value)} />}
|
|
||||||
<GeneratorToggle checked={passwordOptions.special} onChange={(checked) => changePasswordOption('special', checked)} label={t('txt_generator_special')} />
|
|
||||||
{passwordOptions.special && <GeneratorNumberStepper id="min-special" compact label={t('txt_generator_minimum')} value={passwordOptions.minSpecial} minimum={0} maximum={9} fallback={1} onChange={(value) => changePasswordOption('minSpecial', value)} />}
|
|
||||||
</fieldset>
|
|
||||||
<GeneratorToggle checked={passwordOptions.avoidAmbiguous} onChange={(checked) => changePasswordOption('avoidAmbiguous', checked)} label={t('txt_generator_avoid_ambiguous')} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<GeneratorNumberStepper id="words" label={t('txt_generator_words')} value={passphraseOptions.words} minimum={3} maximum={20} fallback={6} onChange={(value) => changePassphraseOption('words', value)} />
|
|
||||||
<label className="generator-number-field" htmlFor="generator-separator"><span>{t('txt_generator_separator')}</span><input id="generator-separator" className="input" type="text" maxLength={1} value={passphraseOptions.separator} onInput={(event) => changePassphraseOption('separator', event.currentTarget.value.slice(0, 1))} /></label>
|
|
||||||
<div className="generator-option-group">
|
<div className="generator-option-group">
|
||||||
<GeneratorToggle checked={passphraseOptions.capitalize} onChange={(checked) => changePassphraseOption('capitalize', checked)} label={t('txt_generator_capitalize')} />
|
<GeneratorToggle checked={settings.passphrase.capitalize} onChange={(checked) => changePassphraseOption('capitalize', checked)} label={t('txt_generator_capitalize')} />
|
||||||
<GeneratorToggle checked={passphraseOptions.includeNumber} onChange={(checked) => changePassphraseOption('includeNumber', checked)} label={t('txt_generator_include_number')} />
|
<GeneratorToggle checked={settings.passphrase.includeNumber} onChange={(checked) => changePassphraseOption('includeNumber', checked)} label={t('txt_generator_include_number')} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{settings.mode === 'pin' && (
|
||||||
|
<>
|
||||||
|
<GeneratorNumberStepper id="pin-length" label={t('txt_generator_length')} value={settings.pin.length} minimum={3} maximum={64} fallback={6} onChange={(value) => changePinOption('length', value)} />
|
||||||
|
<p className="generator-options-note">{t('txt_generator_pin_description')}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{settings.mode === 'username' && (
|
||||||
|
<UsernameOptionFields options={settings.username} onChange={changeUsernameOption} />
|
||||||
|
)}
|
||||||
|
{settings.mode === 'email' && (
|
||||||
|
<EmailOptionFields options={settings.email} onChange={changeEmailOption} />
|
||||||
|
)}
|
||||||
|
{settings.mode === 'sshKey' && (
|
||||||
|
<SshKeyOptionFields options={settings.sshKey} onChange={changeSshKeyOption} />
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PasswordOptionFields(props: { options: PasswordOptions; onChange: <K extends keyof PasswordOptions>(key: K, value: PasswordOptions[K]) => void; onCharacterTypeChange: (key: 'uppercase' | 'lowercase' | 'numbers' | 'special', checked: boolean) => void }) {
|
||||||
|
const { options } = props;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GeneratorNumberStepper id="length" label={t('txt_generator_length')} value={options.length} minimum={5} maximum={128} fallback={16} onChange={(value) => props.onChange('length', value)} />
|
||||||
|
<fieldset className="generator-option-group"><legend>{t('txt_generator_character_types')}</legend>
|
||||||
|
<GeneratorToggle checked={options.uppercase} onChange={(checked) => props.onCharacterTypeChange('uppercase', checked)} label={t('txt_generator_uppercase')} />
|
||||||
|
{options.uppercase && <GeneratorNumberStepper id="min-uppercase" compact label={t('txt_generator_minimum')} value={options.minUppercase} minimum={0} maximum={9} fallback={1} onChange={(value) => props.onChange('minUppercase', value)} />}
|
||||||
|
<GeneratorToggle checked={options.lowercase} onChange={(checked) => props.onCharacterTypeChange('lowercase', checked)} label={t('txt_generator_lowercase')} />
|
||||||
|
{options.lowercase && <GeneratorNumberStepper id="min-lowercase" compact label={t('txt_generator_minimum')} value={options.minLowercase} minimum={0} maximum={9} fallback={1} onChange={(value) => props.onChange('minLowercase', value)} />}
|
||||||
|
<GeneratorToggle checked={options.numbers} onChange={(checked) => props.onCharacterTypeChange('numbers', checked)} label={t('txt_generator_numbers')} />
|
||||||
|
{options.numbers && <GeneratorNumberStepper id="min-numbers" compact label={t('txt_generator_minimum')} value={options.minNumbers} minimum={0} maximum={9} fallback={1} onChange={(value) => props.onChange('minNumbers', value)} />}
|
||||||
|
<GeneratorToggle checked={options.special} onChange={(checked) => props.onCharacterTypeChange('special', checked)} label={t('txt_generator_special')} />
|
||||||
|
{options.special && <GeneratorNumberStepper id="min-special" compact label={t('txt_generator_minimum')} value={options.minSpecial} minimum={0} maximum={9} fallback={1} onChange={(value) => props.onChange('minSpecial', value)} />}
|
||||||
|
</fieldset>
|
||||||
|
<GeneratorToggle checked={options.avoidAmbiguous} onChange={(checked) => props.onChange('avoidAmbiguous', checked)} label={t('txt_generator_avoid_ambiguous')} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsernameOptionFields(props: { options: UsernameOptions; onChange: <K extends keyof UsernameOptions>(key: K, value: UsernameOptions[K]) => void }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GeneratorNumberStepper id="username-words" label={t('txt_generator_words')} value={props.options.words} minimum={1} maximum={10} fallback={2} onChange={(value) => props.onChange('words', value)} />
|
||||||
|
<div className="generator-option-group">
|
||||||
|
<GeneratorToggle checked={props.options.capitalize} onChange={(checked) => props.onChange('capitalize', checked)} label={t('txt_generator_capitalize')} />
|
||||||
|
<GeneratorToggle checked={props.options.includeNumber} onChange={(checked) => props.onChange('includeNumber', checked)} label={t('txt_generator_include_number')} />
|
||||||
|
</div>
|
||||||
|
<label className="generator-select-field" htmlFor="generator-username-word-list"><span>{t('txt_generator_word_list')}</span><select id="generator-username-word-list" className="input" value={props.options.wordList} onChange={(event) => props.onChange('wordList', event.currentTarget.value as 'eff' | 'custom')}><option value="eff">{t('txt_generator_eff_word_list')}</option><option value="custom">{t('txt_generator_custom_word_list')}</option></select></label>
|
||||||
|
{props.options.wordList === 'custom' && <label className="generator-text-field" htmlFor="generator-username-custom-words"><span>{t('txt_generator_custom_words')}</span><textarea id="generator-username-custom-words" className="input generator-word-list-input" rows={6} spellcheck={false} placeholder={t('txt_generator_custom_words_placeholder')} value={props.options.customWords} onInput={(event) => props.onChange('customWords', event.currentTarget.value)} /></label>}
|
||||||
|
<label className="generator-text-field" htmlFor="generator-username-custom-word"><span>{t('txt_generator_custom_word')}</span><input id="generator-username-custom-word" className="input" type="text" autocomplete="off" maxLength={128} value={props.options.customWord} onInput={(event) => props.onChange('customWord', event.currentTarget.value)} /></label>
|
||||||
|
<label className="generator-text-field" htmlFor="generator-username-delimiter"><span>{t('txt_generator_separator')}</span><input id="generator-username-delimiter" className="input" type="text" autocomplete="off" maxLength={8} value={props.options.delimiter} onInput={(event) => props.onChange('delimiter', event.currentTarget.value.slice(0, 8))} /></label>
|
||||||
|
<p className="generator-options-note">{t('txt_generator_long_word_username_description')}</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmailOptionFields(props: { options: EmailOptions; onChange: <K extends keyof EmailOptions>(key: K, value: EmailOptions[K]) => void }) {
|
||||||
|
const types: Array<[EmailMode, string]> = [
|
||||||
|
['plusAddressed', 'txt_generator_plus_addressed_email'],
|
||||||
|
['catchAll', 'txt_generator_catch_all_email'],
|
||||||
|
['subdomain', 'txt_generator_subdomain_email'],
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label className="generator-select-field" htmlFor="generator-email-type"><span>{t('txt_generator_email_type')}</span><select id="generator-email-type" className="input" value={props.options.type} onChange={(event) => props.onChange('type', event.currentTarget.value as EmailMode)}>{types.map(([value, label]) => <option key={value} value={value}>{t(label)}</option>)}</select></label>
|
||||||
|
{props.options.type === 'catchAll'
|
||||||
|
? <label className="generator-text-field" htmlFor="generator-domain"><span>{t('txt_generator_domain')}</span><input id="generator-domain" className="input" type="text" autocomplete="off" value={props.options.domain} onInput={(event) => props.onChange('domain', event.currentTarget.value)} /></label>
|
||||||
|
: <label className="generator-text-field" htmlFor="generator-email"><span>{t('txt_generator_email')}</span><input id="generator-email" className="input" type="email" autocomplete="off" value={props.options.email} onInput={(event) => props.onChange('email', event.currentTarget.value)} /></label>}
|
||||||
|
<p className="generator-options-note">{t('txt_generator_email_description')}</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicKeyWithComment(publicKey: string, comment: string): string {
|
||||||
|
const base = publicKey.trim().split(/\s+/).slice(0, 2).join(' ');
|
||||||
|
const safeComment = comment.replace(/[\r\n]+/g, ' ').trim();
|
||||||
|
return safeComment ? `${base} ${safeComment}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadText(filename: string, value: string): void {
|
||||||
|
const url = URL.createObjectURL(new Blob([value], { type: 'text/plain;charset=utf-8' }));
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename;
|
||||||
|
anchor.style.display = 'none';
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SshKeyOutput(props: { value: GeneratedSshKey | null; loading: boolean; error: string; comment: string }) {
|
||||||
|
if (props.loading) return <div className="generator-key-status"><LoaderCircle size={24} className="generator-spinner" /><span>{t('txt_generator_ssh_generating')}</span></div>;
|
||||||
|
if (props.error) return <div className="generator-key-status error">{props.error}</div>;
|
||||||
|
if (!props.value) return null;
|
||||||
|
const publicKey = publicKeyWithComment(props.value.publicKey, props.comment);
|
||||||
|
const copyField = (value: string) => void copyTextToClipboard(value);
|
||||||
|
return (
|
||||||
|
<div className="generator-key-output">
|
||||||
|
<div className="generator-key-summary"><strong>{props.value.type}{props.value.type === 'RSA' ? ` ${props.value.bits}` : ''}</strong><code>{props.value.fingerprint}</code></div>
|
||||||
|
<div className="generator-key-field"><span>{t('txt_generator_public_key')}</span><code>{publicKey}</code><div className="generator-key-field-actions"><button type="button" className="btn btn-secondary small" onClick={() => copyField(publicKey)}><Copy size={14} />{t('txt_copy')}</button><button type="button" className="btn btn-secondary small" onClick={() => downloadText('id_nodewarden.pub', `${publicKey}\n`)}><Download size={14} />{t('txt_download')}</button></div></div>
|
||||||
|
<details className="generator-private-key"><summary>{t('txt_generator_private_key')}</summary><code>{props.value.privateKey}</code><div className="generator-key-field-actions"><button type="button" className="btn btn-secondary small" onClick={() => copyField(props.value!.privateKey)}><Copy size={14} />{t('txt_copy')}</button><button type="button" className="btn btn-secondary small" onClick={() => downloadText('id_nodewarden', props.value!.privateKey)}><Download size={14} />{t('txt_download')}</button></div></details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SshKeyOptionFields(props: { options: SshKeyOptions; onChange: <K extends keyof SshKeyOptions>(key: K, value: SshKeyOptions[K]) => void }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label className="generator-select-field" htmlFor="generator-ssh-type"><span>{t('txt_generator_ssh_algorithm')}</span><select id="generator-ssh-type" className="input" value={props.options.type} onChange={(event) => props.onChange('type', event.currentTarget.value as SshKeyOptions['type'])}><option value="ed25519">Ed25519</option><option value="rsa">RSA</option></select></label>
|
||||||
|
{props.options.type === 'rsa' && <label className="generator-select-field" htmlFor="generator-rsa-length"><span>{t('txt_generator_key_length')}</span><select id="generator-rsa-length" className="input" value={props.options.rsaLength} onChange={(event) => props.onChange('rsaLength', Number(event.currentTarget.value) as SshKeyOptions['rsaLength'])}><option value={2048}>2048</option><option value={3072}>3072</option><option value={4096}>4096</option></select></label>}
|
||||||
|
<label className="generator-text-field" htmlFor="generator-ssh-comment"><span>{t('txt_generator_ssh_comment')}</span><input id="generator-ssh-comment" className="input" type="text" autocomplete="off" maxLength={256} placeholder="user@example.com" value={props.options.comment} onInput={(event) => props.onChange('comment', event.currentTarget.value)} /></label>
|
||||||
|
<p className="generator-options-note">{t(props.options.type === 'rsa' ? 'txt_generator_ssh_rsa_description' : 'txt_generator_ssh_ed25519_description')}</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function GeneratorToggle(props: { checked: boolean; label: string; onChange: (checked: boolean) => void }) {
|
function GeneratorToggle(props: { checked: boolean; label: string; onChange: (checked: boolean) => void }) {
|
||||||
return <label className="generator-toggle"><input type="checkbox" checked={props.checked} onChange={(event) => props.onChange(event.currentTarget.checked)} /><span aria-hidden="true" /><strong>{props.label}</strong></label>;
|
return <label className="generator-toggle"><input type="checkbox" checked={props.checked} onChange={(event) => props.onChange(event.currentTarget.checked)} /><span aria-hidden="true" /><strong>{props.label}</strong></label>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function GeneratorNumberStepper(props: { id: string; label: string; value: number; minimum: number; maximum: number; fallback: number; compact?: boolean; onChange: (value: number) => void }) {
|
function GeneratorNumberStepper(props: { id: string; label: string; value: number; minimum: number; maximum: number; fallback: number; compact?: boolean; onChange: (value: number) => void }) {
|
||||||
const id = `generator-stepper-${props.id}`;
|
const id = `generator-stepper-${props.id}`;
|
||||||
const setValue = (value: number) => props.onChange(clamp(value, props.minimum, props.maximum, props.fallback));
|
const setValue = (value: number) => props.onChange(clampInteger(value, props.minimum, props.maximum, props.fallback));
|
||||||
return (
|
return (
|
||||||
<div className={`generator-number-field ${props.compact ? 'compact' : ''}`}>
|
<div className={`generator-number-field ${props.compact ? 'compact' : ''}`}>
|
||||||
<label htmlFor={id}>{props.label}</label>
|
<label htmlFor={id}>{props.label}</label>
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { bytesToBase64, decryptBw, encryptBw, hkdfExpand, pbkdf2 } from '../crypto';
|
import {
|
||||||
|
bytesToBase64,
|
||||||
|
decryptBw,
|
||||||
|
encryptBw,
|
||||||
|
hkdfExpand,
|
||||||
|
pbkdf2,
|
||||||
|
requireWebCrypto,
|
||||||
|
WebCryptoUnavailableError,
|
||||||
|
} from '../crypto';
|
||||||
import { t, translateServerError } from '../i18n';
|
import { t, translateServerError } from '../i18n';
|
||||||
import type { AuthorizedDevice } from '../types';
|
import type { AuthorizedDevice } from '../types';
|
||||||
import type {
|
import type {
|
||||||
@@ -428,14 +436,15 @@ export async function registerAccount(args: {
|
|||||||
}): Promise<{ ok: true } | { ok: false; message: string }> {
|
}): Promise<{ ok: true } | { ok: false; message: string }> {
|
||||||
try {
|
try {
|
||||||
const { email, name, password, masterPasswordHint, inviteCode, fallbackIterations } = args;
|
const { email, name, password, masterPasswordHint, inviteCode, fallbackIterations } = args;
|
||||||
|
const webCrypto = requireWebCrypto();
|
||||||
const masterKey = await pbkdf2(password, email, fallbackIterations, 32);
|
const masterKey = await pbkdf2(password, email, fallbackIterations, 32);
|
||||||
const masterHash = await pbkdf2(masterKey, password, 1, 32);
|
const masterHash = await pbkdf2(masterKey, password, 1, 32);
|
||||||
const encKey = await hkdfExpand(masterKey, 'enc', 32);
|
const encKey = await hkdfExpand(masterKey, 'enc', 32);
|
||||||
const macKey = await hkdfExpand(masterKey, 'mac', 32);
|
const macKey = await hkdfExpand(masterKey, 'mac', 32);
|
||||||
const sym = crypto.getRandomValues(new Uint8Array(64));
|
const sym = webCrypto.getRandomValues(new Uint8Array(64));
|
||||||
const encryptedVaultKey = await encryptBw(sym, encKey, macKey);
|
const encryptedVaultKey = await encryptBw(sym, encKey, macKey);
|
||||||
|
|
||||||
const keyPair = await crypto.subtle.generateKey(
|
const keyPair = await webCrypto.subtle.generateKey(
|
||||||
{
|
{
|
||||||
name: 'RSA-OAEP',
|
name: 'RSA-OAEP',
|
||||||
modulusLength: 2048,
|
modulusLength: 2048,
|
||||||
@@ -445,8 +454,8 @@ export async function registerAccount(args: {
|
|||||||
true,
|
true,
|
||||||
['encrypt', 'decrypt']
|
['encrypt', 'decrypt']
|
||||||
);
|
);
|
||||||
const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey));
|
const publicKey = new Uint8Array(await webCrypto.subtle.exportKey('spki', keyPair.publicKey));
|
||||||
const privateKey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey));
|
const privateKey = new Uint8Array(await webCrypto.subtle.exportKey('pkcs8', keyPair.privateKey));
|
||||||
const encryptedPrivateKey = await encryptBw(privateKey, sym.slice(0, 32), sym.slice(32, 64));
|
const encryptedPrivateKey = await encryptBw(privateKey, sym.slice(0, 32), sym.slice(32, 64));
|
||||||
|
|
||||||
const resp = await fetch('/api/accounts/register', {
|
const resp = await fetch('/api/accounts/register', {
|
||||||
@@ -474,6 +483,9 @@ export async function registerAccount(args: {
|
|||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof WebCryptoUnavailableError) {
|
||||||
|
return { ok: false, message: t('txt_web_crypto_unavailable') };
|
||||||
|
}
|
||||||
return { ok: false, message: error instanceof Error ? translateServerError(error.message, error.message) : t('txt_register_failed') };
|
return { ok: false, message: error instanceof Error ? translateServerError(error.message, error.message) : t('txt_register_failed') };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-17
@@ -1,3 +1,34 @@
|
|||||||
|
export const WEB_CRYPTO_UNAVAILABLE_MESSAGE =
|
||||||
|
'Secure browser cryptography is unavailable. Open NodeWarden over HTTPS in a supported browser.';
|
||||||
|
|
||||||
|
export class WebCryptoUnavailableError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super(WEB_CRYPTO_UNAVAILABLE_MESSAGE);
|
||||||
|
this.name = 'WebCryptoUnavailableError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebCryptoEnvironment {
|
||||||
|
crypto?: Crypto;
|
||||||
|
isSecureContext?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireWebCrypto(
|
||||||
|
environment: WebCryptoEnvironment = globalThis as unknown as WebCryptoEnvironment
|
||||||
|
): Crypto {
|
||||||
|
const cryptoApi = environment.crypto;
|
||||||
|
if (
|
||||||
|
environment.isSecureContext === false ||
|
||||||
|
!cryptoApi ||
|
||||||
|
typeof cryptoApi.getRandomValues !== 'function' ||
|
||||||
|
!cryptoApi.subtle ||
|
||||||
|
typeof cryptoApi.subtle.importKey !== 'function'
|
||||||
|
) {
|
||||||
|
throw new WebCryptoUnavailableError();
|
||||||
|
}
|
||||||
|
return cryptoApi;
|
||||||
|
}
|
||||||
|
|
||||||
export function bytesToBase64(bytes: Uint8Array): string {
|
export function bytesToBase64(bytes: Uint8Array): string {
|
||||||
let s = '';
|
let s = '';
|
||||||
for (let i = 0; i < bytes.length; i += 1) s += String.fromCharCode(bytes[i]);
|
for (let i = 0; i < bytes.length; i += 1) s += String.fromCharCode(bytes[i]);
|
||||||
@@ -24,7 +55,7 @@ export function toBufferSource(bytes: Uint8Array): ArrayBuffer {
|
|||||||
|
|
||||||
export async function sha256Base64(value: string): Promise<string> {
|
export async function sha256Base64(value: string): Promise<string> {
|
||||||
const bytes = new TextEncoder().encode(value);
|
const bytes = new TextEncoder().encode(value);
|
||||||
const hash = await crypto.subtle.digest('SHA-256', toBufferSource(bytes));
|
const hash = await requireWebCrypto().subtle.digest('SHA-256', toBufferSource(bytes));
|
||||||
return bytesToBase64(new Uint8Array(hash));
|
return bytesToBase64(new Uint8Array(hash));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +82,7 @@ function getHmacSha256Key(keyBytes: Uint8Array): Promise<CryptoKey> {
|
|||||||
return getCachedCryptoKey(
|
return getCachedCryptoKey(
|
||||||
hmacSha256KeyCache,
|
hmacSha256KeyCache,
|
||||||
keyBytes,
|
keyBytes,
|
||||||
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
|
() => requireWebCrypto().subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +90,7 @@ function getAesCbcEncryptKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
|||||||
return getCachedCryptoKey(
|
return getCachedCryptoKey(
|
||||||
aesCbcEncryptKeyCache,
|
aesCbcEncryptKeyCache,
|
||||||
keyBytes,
|
keyBytes,
|
||||||
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['encrypt'])
|
() => requireWebCrypto().subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['encrypt'])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +98,7 @@ function getAesCbcDecryptKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
|||||||
return getCachedCryptoKey(
|
return getCachedCryptoKey(
|
||||||
aesCbcDecryptKeyCache,
|
aesCbcDecryptKeyCache,
|
||||||
keyBytes,
|
keyBytes,
|
||||||
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['decrypt'])
|
() => requireWebCrypto().subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['decrypt'])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,8 +119,9 @@ export async function pbkdf2(
|
|||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const pwdBytes = typeof passwordOrBytes === 'string' ? new TextEncoder().encode(passwordOrBytes) : passwordOrBytes;
|
const pwdBytes = typeof passwordOrBytes === 'string' ? new TextEncoder().encode(passwordOrBytes) : passwordOrBytes;
|
||||||
const saltBytes = typeof saltOrBytes === 'string' ? new TextEncoder().encode(saltOrBytes) : saltOrBytes;
|
const saltBytes = typeof saltOrBytes === 'string' ? new TextEncoder().encode(saltOrBytes) : saltOrBytes;
|
||||||
const key = await crypto.subtle.importKey('raw', toBufferSource(pwdBytes), 'PBKDF2', false, ['deriveBits']);
|
const subtle = requireWebCrypto().subtle;
|
||||||
const bits = await crypto.subtle.deriveBits(
|
const key = await subtle.importKey('raw', toBufferSource(pwdBytes), 'PBKDF2', false, ['deriveBits']);
|
||||||
|
const bits = await subtle.deriveBits(
|
||||||
{ name: 'PBKDF2', hash: 'SHA-256', salt: toBufferSource(saltBytes), iterations },
|
{ name: 'PBKDF2', hash: 'SHA-256', salt: toBufferSource(saltBytes), iterations },
|
||||||
key,
|
key,
|
||||||
keyLen * 8
|
keyLen * 8
|
||||||
@@ -99,7 +131,8 @@ export async function pbkdf2(
|
|||||||
|
|
||||||
export async function hkdfExpand(prk: Uint8Array, info: string, length: number): Promise<Uint8Array> {
|
export async function hkdfExpand(prk: Uint8Array, info: string, length: number): Promise<Uint8Array> {
|
||||||
const infoBytes = new TextEncoder().encode(info || '');
|
const infoBytes = new TextEncoder().encode(info || '');
|
||||||
const key = await crypto.subtle.importKey('raw', toBufferSource(prk), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
const subtle = requireWebCrypto().subtle;
|
||||||
|
const key = await subtle.importKey('raw', toBufferSource(prk), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||||
const result = new Uint8Array(length);
|
const result = new Uint8Array(length);
|
||||||
let previous = new Uint8Array(0);
|
let previous = new Uint8Array(0);
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
@@ -110,7 +143,7 @@ export async function hkdfExpand(prk: Uint8Array, info: string, length: number):
|
|||||||
input.set(previous, 0);
|
input.set(previous, 0);
|
||||||
input.set(infoBytes, previous.length);
|
input.set(infoBytes, previous.length);
|
||||||
input[input.length - 1] = counter & 0xff;
|
input[input.length - 1] = counter & 0xff;
|
||||||
previous = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(input)));
|
previous = new Uint8Array(await subtle.sign('HMAC', key, toBufferSource(input)));
|
||||||
const copyLen = Math.min(previous.length, length - offset);
|
const copyLen = Math.min(previous.length, length - offset);
|
||||||
result.set(previous.slice(0, copyLen), offset);
|
result.set(previous.slice(0, copyLen), offset);
|
||||||
offset += copyLen;
|
offset += copyLen;
|
||||||
@@ -134,28 +167,29 @@ export async function hkdf(
|
|||||||
info: toBufferSource(infoBytes),
|
info: toBufferSource(infoBytes),
|
||||||
hash: 'SHA-256',
|
hash: 'SHA-256',
|
||||||
};
|
};
|
||||||
const key = await crypto.subtle.importKey('raw', toBufferSource(ikm), 'HKDF', false, ['deriveBits']);
|
const subtle = requireWebCrypto().subtle;
|
||||||
const bits = await crypto.subtle.deriveBits(params, key, outputByteSize * 8);
|
const key = await subtle.importKey('raw', toBufferSource(ikm), 'HKDF', false, ['deriveBits']);
|
||||||
|
const bits = await subtle.deriveBits(params, key, outputByteSize * 8);
|
||||||
return new Uint8Array(bits);
|
return new Uint8Array(bits);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise<Uint8Array> {
|
async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise<Uint8Array> {
|
||||||
const key = await getHmacSha256Key(keyBytes);
|
const key = await getHmacSha256Key(keyBytes);
|
||||||
return new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(dataBytes)));
|
return new Uint8Array(await requireWebCrypto().subtle.sign('HMAC', key, toBufferSource(dataBytes)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function encryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
|
async function encryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
|
||||||
const cryptoKey = await getAesCbcEncryptKey(key);
|
const cryptoKey = await getAesCbcEncryptKey(key);
|
||||||
return new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
|
return new Uint8Array(await requireWebCrypto().subtle.encrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function decryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
|
async function decryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
|
||||||
const cryptoKey = await getAesCbcDecryptKey(key);
|
const cryptoKey = await getAesCbcDecryptKey(key);
|
||||||
return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
|
return new Uint8Array(await requireWebCrypto().subtle.decrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function encryptBwFileData(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<Uint8Array> {
|
export async function encryptBwFileData(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<Uint8Array> {
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(16));
|
const iv = requireWebCrypto().getRandomValues(new Uint8Array(16));
|
||||||
const cipher = await encryptAesCbc(data, encKey, iv);
|
const cipher = await encryptAesCbc(data, encKey, iv);
|
||||||
const mac = await hmacSha256(macKey, concatBytes(iv, cipher));
|
const mac = await hmacSha256(macKey, concatBytes(iv, cipher));
|
||||||
const out = new Uint8Array(1 + iv.length + mac.length + cipher.length);
|
const out = new Uint8Array(1 + iv.length + mac.length + cipher.length);
|
||||||
@@ -179,7 +213,7 @@ export async function decryptBwFileData(encrypted: Uint8Array, encKey: Uint8Arra
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function encryptBw(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<string> {
|
export async function encryptBw(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<string> {
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(16));
|
const iv = requireWebCrypto().getRandomValues(new Uint8Array(16));
|
||||||
const cipher = await encryptAesCbc(data, encKey, iv);
|
const cipher = await encryptAesCbc(data, encKey, iv);
|
||||||
const mac = await hmacSha256(macKey, concatBytes(iv, cipher));
|
const mac = await hmacSha256(macKey, concatBytes(iv, cipher));
|
||||||
return `2.${bytesToBase64(iv)}|${bytesToBase64(cipher)}|${bytesToBase64(mac)}`;
|
return `2.${bytesToBase64(iv)}|${bytesToBase64(cipher)}|${bytesToBase64(mac)}`;
|
||||||
@@ -556,8 +590,9 @@ export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now())
|
|||||||
message[i] = c & 0xff;
|
message[i] = c & 0xff;
|
||||||
c = Math.floor(c / 256);
|
c = Math.floor(c / 256);
|
||||||
}
|
}
|
||||||
const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: algorithm }, false, ['sign']);
|
const subtle = requireWebCrypto().subtle;
|
||||||
const hs = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(message)));
|
const key = await subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: algorithm }, false, ['sign']);
|
||||||
|
const hs = new Uint8Array(await subtle.sign('HMAC', key, toBufferSource(message)));
|
||||||
const offset = hs[hs.length - 1] & 0x0f;
|
const offset = hs[hs.length - 1] & 0x0f;
|
||||||
const bin = ((hs[offset] & 0x7f) << 24) | ((hs[offset + 1] & 0xff) << 16) | ((hs[offset + 2] & 0xff) << 8) | (hs[offset + 3] & 0xff);
|
const bin = ((hs[offset] & 0x7f) << 24) | ((hs[offset + 1] & 0xff) << 16) | ((hs[offset + 2] & 0xff) << 8) | (hs[offset + 3] & 0xff);
|
||||||
let code = (bin % (10 ** digits)).toString().padStart(digits, '0');
|
let code = (bin % (10 ** digits)).toString().padStart(digits, '0');
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ export function translateServerError(message: string | null | undefined, fallbac
|
|||||||
'WebDAV server URL is required': 'txt_backup_error_webdav_url_required',
|
'WebDAV server URL is required': 'txt_backup_error_webdav_url_required',
|
||||||
'WebDAV server URL must start with http:// or https://': 'txt_backup_error_webdav_url_protocol',
|
'WebDAV server URL must start with http:// or https://': 'txt_backup_error_webdav_url_protocol',
|
||||||
'WebDAV username is required': 'txt_backup_error_webdav_username_required',
|
'WebDAV username is required': 'txt_backup_error_webdav_username_required',
|
||||||
|
'Secure browser cryptography is unavailable. Open NodeWarden over HTTPS in a supported browser.': 'txt_web_crypto_unavailable',
|
||||||
'masterPasswordHash is required': 'txt_server_error_master_password_hash_required',
|
'masterPasswordHash is required': 'txt_server_error_master_password_hash_required',
|
||||||
'masterPasswordHash or userVerificationToken is required': 'txt_server_error_master_password_or_verification_required',
|
'masterPasswordHash or userVerificationToken is required': 'txt_server_error_master_password_or_verification_required',
|
||||||
}[normalized];
|
}[normalized];
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ const de: Record<string, string> = {
|
|||||||
"nav_vault_items": "Tresor",
|
"nav_vault_items": "Tresor",
|
||||||
"nav_sends": "Sendungen",
|
"nav_sends": "Sendungen",
|
||||||
"nav_generator": "Passwortwerkzeug", "txt_password_generator": "Passwortgenerator", "txt_password_generator_description": "Erstellen Sie lokal auf diesem Gerät ein starkes, einzigartiges Passwort.", "txt_generator_type": "Generatortyp", "txt_passphrase": "Kennwortsatz", "txt_generated_password": "Generiertes Passwort", "txt_password_strength": "Stärke", "txt_password_strength_weak": "Schwach", "txt_password_strength_fair": "Mittel", "txt_password_strength_good": "Gut", "txt_password_strength_strong": "Stark", "txt_generator_security_note": "Die Erzeugung erfolgt lokal. Ihr Passwort wird nie an den Server gesendet.", "txt_generator_length": "Länge", "txt_generator_character_types": "Zeichentypen", "txt_generator_uppercase": "Großbuchstaben (A-Z)", "txt_generator_lowercase": "Kleinbuchstaben (a-z)", "txt_generator_numbers": "Zahlen (0-9)", "txt_generator_special": "Sonderzeichen (!@#$%^&*)", "txt_generator_minimum": "Mindestanzahl", "txt_generator_avoid_ambiguous": "Verwechselbare Zeichen vermeiden", "txt_generator_words": "Anzahl der Wörter", "txt_generator_separator": "Worttrenner", "txt_generator_capitalize": "Großschreibung", "txt_generator_include_number": "Eine Zahl einfügen",
|
"nav_generator": "Passwortwerkzeug", "txt_password_generator": "Passwortgenerator", "txt_password_generator_description": "Erstellen Sie lokal auf diesem Gerät ein starkes, einzigartiges Passwort.", "txt_generator_type": "Generatortyp", "txt_passphrase": "Kennwortsatz", "txt_generated_password": "Generiertes Passwort", "txt_password_strength": "Stärke", "txt_password_strength_weak": "Schwach", "txt_password_strength_fair": "Mittel", "txt_password_strength_good": "Gut", "txt_password_strength_strong": "Stark", "txt_generator_security_note": "Die Erzeugung erfolgt lokal. Ihr Passwort wird nie an den Server gesendet.", "txt_generator_length": "Länge", "txt_generator_character_types": "Zeichentypen", "txt_generator_uppercase": "Großbuchstaben (A-Z)", "txt_generator_lowercase": "Kleinbuchstaben (a-z)", "txt_generator_numbers": "Zahlen (0-9)", "txt_generator_special": "Sonderzeichen (!@#$%^&*)", "txt_generator_minimum": "Mindestanzahl", "txt_generator_avoid_ambiguous": "Verwechselbare Zeichen vermeiden", "txt_generator_words": "Anzahl der Wörter", "txt_generator_separator": "Worttrenner", "txt_generator_capitalize": "Großschreibung", "txt_generator_include_number": "Eine Zahl einfügen",
|
||||||
|
"txt_generated_value": "Erzeugter Wert", "txt_generator_character_count": "{count} Zeichen", "txt_generator_pin": "PIN-Code", "txt_generator_pin_description": "Erzeugt lokal eine numerische PIN mit kryptografisch sicheren Zufallswerten.", "txt_generator_username": "Benutzername", "txt_generator_plus_addressed_email": "E-Mail mit Plus-Adressierung", "txt_generator_catch_all_email": "Catch-all-E-Mail", "txt_generator_email": "E-Mail-Adresse", "txt_generator_domain": "Domäne",
|
||||||
|
"txt_generator_word_list": "Wortliste", "txt_generator_eff_word_list": "Lange EFF-Wortliste", "txt_generator_custom_word_list": "Eigene Wortliste", "txt_generator_custom_words": "Eigene Wörter", "txt_generator_custom_words_placeholder": "Mindestens zwei Wörter durch Leerzeichen, Kommas oder Zeilen trennen",
|
||||||
|
"txt_generator_long_word_username": "Langwort-Benutzername", "txt_generator_custom_word": "Eigenes Merkwort", "txt_generator_subdomain_email": "Subdomain-E-Mail", "txt_generator_ssh_key": "SSH-Schlüssel", "txt_generator_ssh_generating": "SSH-Schlüssel wird erzeugt…", "txt_generator_ssh_error": "Dieser Browser unterstützt keine SSH-Schlüsselerzeugung.", "txt_generator_copy_public_key": "Öffentlichen Schlüssel kopieren", "txt_generator_ssh_security_note": "Das Schlüsselpaar wird lokal erzeugt und nicht gesendet. Speichern Sie den privaten Schlüssel jetzt; NodeWarden behält ihn nicht.", "txt_generator_public_key": "Öffentlicher Schlüssel", "txt_generator_private_key": "Privater Schlüssel — geheim halten", "txt_generator_ssh_algorithm": "Algorithmus", "txt_generator_key_length": "Schlüssellänge", "txt_generator_ssh_comment": "Kommentar des öffentlichen Schlüssels", "txt_generator_ssh_rsa_description": "RSA ist breit kompatibel. Mindestens 2048 Bit verwenden; 4096 Bit werden empfohlen.", "txt_generator_ssh_ed25519_description": "Ed25519 ist schnell, kompakt und für moderne SSH-Clients empfohlen.",
|
||||||
|
"txt_generator_email_alias": "E-Mail-Alias", "txt_generator_email_type": "Alias-Typ", "txt_generator_email_required_hint": "E-Mail-Adresse oder Domain eingeben, um einen Alias zu erzeugen.", "txt_generator_email_description": "Ihre Eingabe bleibt auf diesem Gerät und wird nur zum Zusammensetzen des Alias verwendet.", "txt_generator_long_word_username_description": "Ein oder mehrere lange Wörter zu einem einprägsamen Benutzernamen kombinieren.",
|
||||||
"nav_backup_strategy": "Cloud-Backup",
|
"nav_backup_strategy": "Cloud-Backup",
|
||||||
"nav_import_export": "Import und Export",
|
"nav_import_export": "Import und Export",
|
||||||
"nav_group_data_backup": "Daten & Backup",
|
"nav_group_data_backup": "Daten & Backup",
|
||||||
@@ -511,6 +515,7 @@ const de: Record<string, string> = {
|
|||||||
"txt_create_account": "Konto erstellen",
|
"txt_create_account": "Konto erstellen",
|
||||||
"txt_registering": "Konto wird erstellt...",
|
"txt_registering": "Konto wird erstellt...",
|
||||||
"txt_register_failed": "Registrierung fehlgeschlagen",
|
"txt_register_failed": "Registrierung fehlgeschlagen",
|
||||||
|
"txt_web_crypto_unavailable": "Sichere Browser-Kryptografie ist nicht verfügbar. Öffnen Sie NodeWarden über HTTPS in einem unterstützten Browser.",
|
||||||
"txt_create_folder": "Ordner erstellen",
|
"txt_create_folder": "Ordner erstellen",
|
||||||
"txt_create_folder_failed": "Fehler beim Erstellen des Ordners",
|
"txt_create_folder_failed": "Fehler beim Erstellen des Ordners",
|
||||||
"txt_create_item_failed": "Fehler beim Erstellen des Eintrags",
|
"txt_create_item_failed": "Fehler beim Erstellen des Eintrags",
|
||||||
|
|||||||
@@ -31,6 +31,40 @@ const en: Record<string, string> = {
|
|||||||
"txt_generator_separator": "Word separator",
|
"txt_generator_separator": "Word separator",
|
||||||
"txt_generator_capitalize": "Capitalize",
|
"txt_generator_capitalize": "Capitalize",
|
||||||
"txt_generator_include_number": "Include a number",
|
"txt_generator_include_number": "Include a number",
|
||||||
|
"txt_generated_value": "Generated value",
|
||||||
|
"txt_generator_character_count": "{count} characters",
|
||||||
|
"txt_generator_pin": "PIN",
|
||||||
|
"txt_generator_pin_description": "Generate a numeric PIN locally using cryptographically secure randomness.",
|
||||||
|
"txt_generator_username": "Username",
|
||||||
|
"txt_generator_plus_addressed_email": "Plus-addressed email",
|
||||||
|
"txt_generator_catch_all_email": "Catch-all email",
|
||||||
|
"txt_generator_email": "Email address",
|
||||||
|
"txt_generator_domain": "Domain",
|
||||||
|
"txt_generator_word_list": "Word list",
|
||||||
|
"txt_generator_eff_word_list": "EFF long word list",
|
||||||
|
"txt_generator_custom_word_list": "Custom word list",
|
||||||
|
"txt_generator_custom_words": "Custom words",
|
||||||
|
"txt_generator_custom_words_placeholder": "Enter at least two words separated by spaces, commas, or new lines",
|
||||||
|
"txt_generator_long_word_username": "Long-word username",
|
||||||
|
"txt_generator_custom_word": "Custom helper word",
|
||||||
|
"txt_generator_subdomain_email": "Subdomain email",
|
||||||
|
"txt_generator_ssh_key": "SSH key",
|
||||||
|
"txt_generator_ssh_generating": "Generating SSH key…",
|
||||||
|
"txt_generator_ssh_error": "SSH key generation is not supported by this browser.",
|
||||||
|
"txt_generator_copy_public_key": "Copy public key",
|
||||||
|
"txt_generator_ssh_security_note": "The key pair is generated locally and is never sent to the server. Save the private key now; NodeWarden does not retain it.",
|
||||||
|
"txt_generator_public_key": "Public key",
|
||||||
|
"txt_generator_private_key": "Private key — keep secret",
|
||||||
|
"txt_generator_ssh_algorithm": "Algorithm",
|
||||||
|
"txt_generator_key_length": "Key length",
|
||||||
|
"txt_generator_ssh_comment": "Public key comment",
|
||||||
|
"txt_generator_ssh_rsa_description": "RSA is widely compatible. Use at least 2048 bits; 4096 bits is recommended.",
|
||||||
|
"txt_generator_ssh_ed25519_description": "Ed25519 is fast, compact, and recommended for modern SSH clients.",
|
||||||
|
"txt_generator_email_alias": "Email alias",
|
||||||
|
"txt_generator_email_type": "Email alias type",
|
||||||
|
"txt_generator_email_required_hint": "Enter your email address or domain to generate an alias.",
|
||||||
|
"txt_generator_email_description": "Your address stays on this device and is only used to compose the alias.",
|
||||||
|
"txt_generator_long_word_username_description": "Combine one or more long words into a memorable username.",
|
||||||
"nav_backup_strategy": "Cloud Backup",
|
"nav_backup_strategy": "Cloud Backup",
|
||||||
"nav_import_export": "Import & Export",
|
"nav_import_export": "Import & Export",
|
||||||
"nav_group_data_backup": "Data & Backup",
|
"nav_group_data_backup": "Data & Backup",
|
||||||
@@ -534,6 +568,7 @@ const en: Record<string, string> = {
|
|||||||
"txt_create_account": "Create Account",
|
"txt_create_account": "Create Account",
|
||||||
"txt_registering": "Creating account...",
|
"txt_registering": "Creating account...",
|
||||||
"txt_register_failed": "Register failed",
|
"txt_register_failed": "Register failed",
|
||||||
|
"txt_web_crypto_unavailable": "Secure browser cryptography is unavailable. Open NodeWarden over HTTPS in a supported browser.",
|
||||||
"txt_create_folder": "Create Folder",
|
"txt_create_folder": "Create Folder",
|
||||||
"txt_create_folder_failed": "Create folder failed",
|
"txt_create_folder_failed": "Create folder failed",
|
||||||
"txt_create_item_failed": "Create item failed",
|
"txt_create_item_failed": "Create item failed",
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ const es: Record<string, string> = {
|
|||||||
"nav_vault_items": "Bóveda",
|
"nav_vault_items": "Bóveda",
|
||||||
"nav_sends": "Envíos",
|
"nav_sends": "Envíos",
|
||||||
"nav_generator": "Generador", "txt_password_generator": "Generador de contraseñas", "txt_password_generator_description": "Crea una contraseña única y segura localmente en este dispositivo.", "txt_generator_type": "Tipo de generador", "txt_passphrase": "Frase de contraseña", "txt_generated_password": "Contraseña generada", "txt_password_strength": "Seguridad", "txt_password_strength_weak": "Débil", "txt_password_strength_fair": "Regular", "txt_password_strength_good": "Buena", "txt_password_strength_strong": "Fuerte", "txt_generator_security_note": "La generación se realiza localmente. Tu contraseña nunca se envía al servidor.", "txt_generator_length": "Longitud", "txt_generator_character_types": "Tipos de caracteres", "txt_generator_uppercase": "Mayúsculas (A-Z)", "txt_generator_lowercase": "Minúsculas (a-z)", "txt_generator_numbers": "Números (0-9)", "txt_generator_special": "Caracteres especiales (!@#$%^&*)", "txt_generator_minimum": "Mínimo", "txt_generator_avoid_ambiguous": "Evitar caracteres ambiguos", "txt_generator_words": "Número de palabras", "txt_generator_separator": "Separador de palabras", "txt_generator_capitalize": "Usar mayúsculas", "txt_generator_include_number": "Incluir un número",
|
"nav_generator": "Generador", "txt_password_generator": "Generador de contraseñas", "txt_password_generator_description": "Crea una contraseña única y segura localmente en este dispositivo.", "txt_generator_type": "Tipo de generador", "txt_passphrase": "Frase de contraseña", "txt_generated_password": "Contraseña generada", "txt_password_strength": "Seguridad", "txt_password_strength_weak": "Débil", "txt_password_strength_fair": "Regular", "txt_password_strength_good": "Buena", "txt_password_strength_strong": "Fuerte", "txt_generator_security_note": "La generación se realiza localmente. Tu contraseña nunca se envía al servidor.", "txt_generator_length": "Longitud", "txt_generator_character_types": "Tipos de caracteres", "txt_generator_uppercase": "Mayúsculas (A-Z)", "txt_generator_lowercase": "Minúsculas (a-z)", "txt_generator_numbers": "Números (0-9)", "txt_generator_special": "Caracteres especiales (!@#$%^&*)", "txt_generator_minimum": "Mínimo", "txt_generator_avoid_ambiguous": "Evitar caracteres ambiguos", "txt_generator_words": "Número de palabras", "txt_generator_separator": "Separador de palabras", "txt_generator_capitalize": "Usar mayúsculas", "txt_generator_include_number": "Incluir un número",
|
||||||
|
"txt_generated_value": "Valor generado", "txt_generator_character_count": "{count} caracteres", "txt_generator_pin": "PIN", "txt_generator_pin_description": "Genera localmente un PIN numérico con aleatoriedad criptográficamente segura.", "txt_generator_username": "Nombre de usuario", "txt_generator_plus_addressed_email": "Correo con direccionamiento plus", "txt_generator_catch_all_email": "Correo catch-all", "txt_generator_email": "Correo electrónico", "txt_generator_domain": "Dominio",
|
||||||
|
"txt_generator_word_list": "Lista de palabras", "txt_generator_eff_word_list": "Lista larga de EFF", "txt_generator_custom_word_list": "Lista personalizada", "txt_generator_custom_words": "Palabras personalizadas", "txt_generator_custom_words_placeholder": "Introduce al menos dos palabras separadas por espacios, comas o líneas",
|
||||||
|
"txt_generator_long_word_username": "Usuario de palabras largas", "txt_generator_custom_word": "Palabra auxiliar personalizada", "txt_generator_subdomain_email": "Correo de subdominio", "txt_generator_ssh_key": "Clave SSH", "txt_generator_ssh_generating": "Generando clave SSH…", "txt_generator_ssh_error": "Este navegador no admite la generación de claves SSH.", "txt_generator_copy_public_key": "Copiar clave pública", "txt_generator_ssh_security_note": "El par se genera localmente y no se envía. Guarda ahora la clave privada; NodeWarden no la conserva.", "txt_generator_public_key": "Clave pública", "txt_generator_private_key": "Clave privada — mantenla en secreto", "txt_generator_ssh_algorithm": "Algoritmo", "txt_generator_key_length": "Longitud de clave", "txt_generator_ssh_comment": "Comentario de clave pública", "txt_generator_ssh_rsa_description": "RSA es ampliamente compatible. Usa al menos 2048 bits; se recomiendan 4096.", "txt_generator_ssh_ed25519_description": "Ed25519 es rápido, compacto y recomendado para clientes SSH modernos.",
|
||||||
|
"txt_generator_email_alias": "Alias de correo", "txt_generator_email_type": "Tipo de alias", "txt_generator_email_required_hint": "Introduce tu correo o dominio para generar un alias.", "txt_generator_email_description": "Los datos permanecen en este dispositivo y solo se usan para formar el alias.", "txt_generator_long_word_username_description": "Combina una o más palabras largas en un nombre de usuario fácil de recordar.",
|
||||||
"nav_backup_strategy": "Copia de seguridad en la nube",
|
"nav_backup_strategy": "Copia de seguridad en la nube",
|
||||||
"nav_import_export": "Importar y exportar",
|
"nav_import_export": "Importar y exportar",
|
||||||
"nav_group_data_backup": "Datos y copias",
|
"nav_group_data_backup": "Datos y copias",
|
||||||
@@ -511,6 +515,7 @@ const es: Record<string, string> = {
|
|||||||
"txt_create_account": "Crear cuenta",
|
"txt_create_account": "Crear cuenta",
|
||||||
"txt_registering": "Creando cuenta...",
|
"txt_registering": "Creando cuenta...",
|
||||||
"txt_register_failed": "Error al registrarse",
|
"txt_register_failed": "Error al registrarse",
|
||||||
|
"txt_web_crypto_unavailable": "La criptografía segura del navegador no está disponible. Abra NodeWarden mediante HTTPS en un navegador compatible.",
|
||||||
"txt_create_folder": "Crear carpeta",
|
"txt_create_folder": "Crear carpeta",
|
||||||
"txt_create_folder_failed": "Error al crear carpeta",
|
"txt_create_folder_failed": "Error al crear carpeta",
|
||||||
"txt_create_item_failed": "Error al crear elemento",
|
"txt_create_item_failed": "Error al crear elemento",
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ const fi: Record<string, string> = {
|
|||||||
"nav_vault_items": "Holvi",
|
"nav_vault_items": "Holvi",
|
||||||
"nav_sends": "Lähetykset",
|
"nav_sends": "Lähetykset",
|
||||||
"nav_generator": "Luoja", "txt_password_generator": "Salasanageneraattori", "txt_password_generator_description": "Luo vahva ja yksilöllinen salasana paikallisesti tällä laitteella.", "txt_generator_type": "Generaattorin tyyppi", "txt_passphrase": "Salalause", "txt_generated_password": "Luotu salasana", "txt_password_strength": "Vahvuus", "txt_password_strength_weak": "Heikko", "txt_password_strength_fair": "Kohtalainen", "txt_password_strength_good": "Hyvä", "txt_password_strength_strong": "Vahva", "txt_generator_security_note": "Generointi tapahtuu paikallisesti. Salasanaa ei koskaan lähetetä palvelimelle.", "txt_generator_length": "Pituus", "txt_generator_character_types": "Merkkityypit", "txt_generator_uppercase": "Isot kirjaimet (A-Z)", "txt_generator_lowercase": "Pienet kirjaimet (a-z)", "txt_generator_numbers": "Numerot (0-9)", "txt_generator_special": "Erikoismerkit (!@#$%^&*)", "txt_generator_minimum": "Vähintään", "txt_generator_avoid_ambiguous": "Vältä epäselviä merkkejä", "txt_generator_words": "Sanojen määrä", "txt_generator_separator": "Sanaerotin", "txt_generator_capitalize": "Iso alkukirjain", "txt_generator_include_number": "Sisällytä numero",
|
"nav_generator": "Luoja", "txt_password_generator": "Salasanageneraattori", "txt_password_generator_description": "Luo vahva ja yksilöllinen salasana paikallisesti tällä laitteella.", "txt_generator_type": "Generaattorin tyyppi", "txt_passphrase": "Salalause", "txt_generated_password": "Luotu salasana", "txt_password_strength": "Vahvuus", "txt_password_strength_weak": "Heikko", "txt_password_strength_fair": "Kohtalainen", "txt_password_strength_good": "Hyvä", "txt_password_strength_strong": "Vahva", "txt_generator_security_note": "Generointi tapahtuu paikallisesti. Salasanaa ei koskaan lähetetä palvelimelle.", "txt_generator_length": "Pituus", "txt_generator_character_types": "Merkkityypit", "txt_generator_uppercase": "Isot kirjaimet (A-Z)", "txt_generator_lowercase": "Pienet kirjaimet (a-z)", "txt_generator_numbers": "Numerot (0-9)", "txt_generator_special": "Erikoismerkit (!@#$%^&*)", "txt_generator_minimum": "Vähintään", "txt_generator_avoid_ambiguous": "Vältä epäselviä merkkejä", "txt_generator_words": "Sanojen määrä", "txt_generator_separator": "Sanaerotin", "txt_generator_capitalize": "Iso alkukirjain", "txt_generator_include_number": "Sisällytä numero",
|
||||||
|
"txt_generated_value": "Luotu arvo", "txt_generator_character_count": "{count} merkkiä", "txt_generator_pin": "PIN", "txt_generator_pin_description": "Luo numeerisen PIN-koodin paikallisesti kryptografisesti turvallisella satunnaisuudella.", "txt_generator_username": "Käyttäjänimi", "txt_generator_plus_addressed_email": "Plus-osoitteinen sähköposti", "txt_generator_catch_all_email": "Catch-all-sähköposti", "txt_generator_email": "Sähköpostiosoite", "txt_generator_domain": "Verkkotunnus",
|
||||||
|
"txt_generator_word_list": "Sanalista", "txt_generator_eff_word_list": "Pitkä EFF-sanalista", "txt_generator_custom_word_list": "Oma sanalista", "txt_generator_custom_words": "Omat sanat", "txt_generator_custom_words_placeholder": "Anna vähintään kaksi sanaa välilyönnein, pilkuin tai riveittäin",
|
||||||
|
"txt_generator_long_word_username": "Pitkäsanainen käyttäjänimi", "txt_generator_custom_word": "Oma muistisanaa", "txt_generator_subdomain_email": "Aliverkkotunnuksen sähköposti", "txt_generator_ssh_key": "SSH-avain", "txt_generator_ssh_generating": "Luodaan SSH-avainta…", "txt_generator_ssh_error": "Selain ei tue SSH-avaimen luontia.", "txt_generator_copy_public_key": "Kopioi julkinen avain", "txt_generator_ssh_security_note": "Avainpari luodaan paikallisesti eikä sitä lähetetä. Tallenna yksityinen avain nyt; NodeWarden ei säilytä sitä.", "txt_generator_public_key": "Julkinen avain", "txt_generator_private_key": "Yksityinen avain — pidä salassa", "txt_generator_ssh_algorithm": "Algoritmi", "txt_generator_key_length": "Avaimen pituus", "txt_generator_ssh_comment": "Julkisen avaimen kommentti", "txt_generator_ssh_rsa_description": "RSA on laajasti yhteensopiva. Käytä vähintään 2048 bittiä; 4096 on suositus.", "txt_generator_ssh_ed25519_description": "Ed25519 on nopea ja kompakti sekä suositeltu moderneille SSH-asiakkaille.",
|
||||||
|
"txt_generator_email_alias": "Sähköpostialias", "txt_generator_email_type": "Aliaksen tyyppi", "txt_generator_email_required_hint": "Anna sähköpostiosoite tai verkkotunnus aliaksen luomiseksi.", "txt_generator_email_description": "Syöte pysyy tällä laitteella ja sitä käytetään vain aliaksen muodostamiseen.", "txt_generator_long_word_username_description": "Yhdistä yksi tai useampi pitkä sana helposti muistettavaksi käyttäjänimeksi.",
|
||||||
"nav_backup_strategy": "Pilvivarmuuskopiointi",
|
"nav_backup_strategy": "Pilvivarmuuskopiointi",
|
||||||
"nav_import_export": "Tuonti ja Vienti",
|
"nav_import_export": "Tuonti ja Vienti",
|
||||||
"nav_group_data_backup": "Data & Varmuuskopiointi",
|
"nav_group_data_backup": "Data & Varmuuskopiointi",
|
||||||
@@ -511,6 +515,7 @@ const fi: Record<string, string> = {
|
|||||||
"txt_create_account": "Luo tili",
|
"txt_create_account": "Luo tili",
|
||||||
"txt_registering": "Luodaan tiliä...",
|
"txt_registering": "Luodaan tiliä...",
|
||||||
"txt_register_failed": "Rekisteröinti epäonnistui",
|
"txt_register_failed": "Rekisteröinti epäonnistui",
|
||||||
|
"txt_web_crypto_unavailable": "Selaimen suojattu salaus ei ole käytettävissä. Avaa NodeWarden HTTPS-yhteydellä tuetussa selaimessa.",
|
||||||
"txt_create_folder": "Luo kansio",
|
"txt_create_folder": "Luo kansio",
|
||||||
"txt_create_folder_failed": "Kansion luonti epäonnistui",
|
"txt_create_folder_failed": "Kansion luonti epäonnistui",
|
||||||
"txt_create_item_failed": "Nimikkeen luonti epäonnistui",
|
"txt_create_item_failed": "Nimikkeen luonti epäonnistui",
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ const fr: Record<string, string> = {
|
|||||||
"nav_vault_items": "Coffre-fort",
|
"nav_vault_items": "Coffre-fort",
|
||||||
"nav_sends": "Envois",
|
"nav_sends": "Envois",
|
||||||
"nav_generator": "Générateur", "txt_password_generator": "Générateur de mots de passe", "txt_password_generator_description": "Créez un mot de passe fort et unique localement sur cet appareil.", "txt_generator_type": "Type de générateur", "txt_passphrase": "Phrase secrète", "txt_generated_password": "Mot de passe généré", "txt_password_strength": "Robustesse", "txt_password_strength_weak": "Faible", "txt_password_strength_fair": "Correcte", "txt_password_strength_good": "Bonne", "txt_password_strength_strong": "Forte", "txt_generator_security_note": "La génération est locale. Votre mot de passe n'est jamais envoyé au serveur.", "txt_generator_length": "Longueur", "txt_generator_character_types": "Types de caractères", "txt_generator_uppercase": "Majuscules (A-Z)", "txt_generator_lowercase": "Minuscules (a-z)", "txt_generator_numbers": "Chiffres (0-9)", "txt_generator_special": "Caractères spéciaux (!@#$%^&*)", "txt_generator_minimum": "Minimum", "txt_generator_avoid_ambiguous": "Éviter les caractères ambigus", "txt_generator_words": "Nombre de mots", "txt_generator_separator": "Séparateur de mots", "txt_generator_capitalize": "Mettre une majuscule", "txt_generator_include_number": "Inclure un chiffre",
|
"nav_generator": "Générateur", "txt_password_generator": "Générateur de mots de passe", "txt_password_generator_description": "Créez un mot de passe fort et unique localement sur cet appareil.", "txt_generator_type": "Type de générateur", "txt_passphrase": "Phrase secrète", "txt_generated_password": "Mot de passe généré", "txt_password_strength": "Robustesse", "txt_password_strength_weak": "Faible", "txt_password_strength_fair": "Correcte", "txt_password_strength_good": "Bonne", "txt_password_strength_strong": "Forte", "txt_generator_security_note": "La génération est locale. Votre mot de passe n'est jamais envoyé au serveur.", "txt_generator_length": "Longueur", "txt_generator_character_types": "Types de caractères", "txt_generator_uppercase": "Majuscules (A-Z)", "txt_generator_lowercase": "Minuscules (a-z)", "txt_generator_numbers": "Chiffres (0-9)", "txt_generator_special": "Caractères spéciaux (!@#$%^&*)", "txt_generator_minimum": "Minimum", "txt_generator_avoid_ambiguous": "Éviter les caractères ambigus", "txt_generator_words": "Nombre de mots", "txt_generator_separator": "Séparateur de mots", "txt_generator_capitalize": "Mettre une majuscule", "txt_generator_include_number": "Inclure un chiffre",
|
||||||
|
"txt_generated_value": "Valeur générée", "txt_generator_character_count": "{count} caractères", "txt_generator_pin": "PIN", "txt_generator_pin_description": "Génère localement un PIN numérique avec un aléa cryptographiquement sûr.", "txt_generator_username": "Nom d’utilisateur", "txt_generator_plus_addressed_email": "Adresse e-mail avec plus", "txt_generator_catch_all_email": "Adresse e-mail catch-all", "txt_generator_email": "Adresse e-mail", "txt_generator_domain": "Domaine",
|
||||||
|
"txt_generator_word_list": "Liste de mots", "txt_generator_eff_word_list": "Liste longue EFF", "txt_generator_custom_word_list": "Liste personnalisée", "txt_generator_custom_words": "Mots personnalisés", "txt_generator_custom_words_placeholder": "Saisissez au moins deux mots séparés par des espaces, virgules ou lignes",
|
||||||
|
"txt_generator_long_word_username": "Identifiant à mots longs", "txt_generator_custom_word": "Mot aide-mémoire personnalisé", "txt_generator_subdomain_email": "E-mail de sous-domaine", "txt_generator_ssh_key": "Clé SSH", "txt_generator_ssh_generating": "Génération de la clé SSH…", "txt_generator_ssh_error": "Ce navigateur ne prend pas en charge la génération de clés SSH.", "txt_generator_copy_public_key": "Copier la clé publique", "txt_generator_ssh_security_note": "La paire est générée localement et jamais envoyée. Enregistrez la clé privée maintenant ; NodeWarden ne la conserve pas.", "txt_generator_public_key": "Clé publique", "txt_generator_private_key": "Clé privée — à garder secrète", "txt_generator_ssh_algorithm": "Algorithme", "txt_generator_key_length": "Longueur de clé", "txt_generator_ssh_comment": "Commentaire de clé publique", "txt_generator_ssh_rsa_description": "RSA est largement compatible. Utilisez au moins 2048 bits ; 4096 sont recommandés.", "txt_generator_ssh_ed25519_description": "Ed25519 est rapide, compact et recommandé pour les clients SSH modernes.",
|
||||||
|
"txt_generator_email_alias": "Alias e-mail", "txt_generator_email_type": "Type d’alias", "txt_generator_email_required_hint": "Saisissez votre adresse e-mail ou domaine pour générer un alias.", "txt_generator_email_description": "La saisie reste sur cet appareil et sert uniquement à composer l’alias.", "txt_generator_long_word_username_description": "Combinez un ou plusieurs mots longs en un identifiant mémorable.",
|
||||||
"nav_backup_strategy": "Sauvegarde Cloud",
|
"nav_backup_strategy": "Sauvegarde Cloud",
|
||||||
"nav_import_export": "Importer & Exporter",
|
"nav_import_export": "Importer & Exporter",
|
||||||
"nav_group_data_backup": "Données & Sauvegarde",
|
"nav_group_data_backup": "Données & Sauvegarde",
|
||||||
@@ -511,6 +515,7 @@ const fr: Record<string, string> = {
|
|||||||
"txt_create_account": "Créer un compte",
|
"txt_create_account": "Créer un compte",
|
||||||
"txt_registering": "Création du compte...",
|
"txt_registering": "Création du compte...",
|
||||||
"txt_register_failed": "L'inscription a échoué",
|
"txt_register_failed": "L'inscription a échoué",
|
||||||
|
"txt_web_crypto_unavailable": "La cryptographie sécurisée du navigateur n’est pas disponible. Ouvrez NodeWarden via HTTPS dans un navigateur compatible.",
|
||||||
"txt_create_folder": "Créer un dossier",
|
"txt_create_folder": "Créer un dossier",
|
||||||
"txt_create_folder_failed": "La création du dossier a échoué",
|
"txt_create_folder_failed": "La création du dossier a échoué",
|
||||||
"txt_create_item_failed": "La création de l'élément a échoué",
|
"txt_create_item_failed": "La création de l'élément a échoué",
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ const it: Record<string, string> = {
|
|||||||
"nav_vault_items": "Cassaforte",
|
"nav_vault_items": "Cassaforte",
|
||||||
"nav_sends": "Invii",
|
"nav_sends": "Invii",
|
||||||
"nav_generator": "Generatore", "txt_password_generator": "Generatore di password", "txt_password_generator_description": "Crea una password forte e univoca localmente su questo dispositivo.", "txt_generator_type": "Tipo di generatore", "txt_passphrase": "Frase segreta", "txt_generated_password": "Password generata", "txt_password_strength": "Robustezza", "txt_password_strength_weak": "Debole", "txt_password_strength_fair": "Discreta", "txt_password_strength_good": "Buona", "txt_password_strength_strong": "Forte", "txt_generator_security_note": "La generazione avviene localmente. La password non viene mai inviata al server.", "txt_generator_length": "Lunghezza", "txt_generator_character_types": "Tipi di caratteri", "txt_generator_uppercase": "Maiuscole (A-Z)", "txt_generator_lowercase": "Minuscole (a-z)", "txt_generator_numbers": "Numeri (0-9)", "txt_generator_special": "Caratteri speciali (!@#$%^&*)", "txt_generator_minimum": "Minimo", "txt_generator_avoid_ambiguous": "Evita caratteri ambigui", "txt_generator_words": "Numero di parole", "txt_generator_separator": "Separatore di parole", "txt_generator_capitalize": "Iniziale maiuscola", "txt_generator_include_number": "Includi un numero",
|
"nav_generator": "Generatore", "txt_password_generator": "Generatore di password", "txt_password_generator_description": "Crea una password forte e univoca localmente su questo dispositivo.", "txt_generator_type": "Tipo di generatore", "txt_passphrase": "Frase segreta", "txt_generated_password": "Password generata", "txt_password_strength": "Robustezza", "txt_password_strength_weak": "Debole", "txt_password_strength_fair": "Discreta", "txt_password_strength_good": "Buona", "txt_password_strength_strong": "Forte", "txt_generator_security_note": "La generazione avviene localmente. La password non viene mai inviata al server.", "txt_generator_length": "Lunghezza", "txt_generator_character_types": "Tipi di caratteri", "txt_generator_uppercase": "Maiuscole (A-Z)", "txt_generator_lowercase": "Minuscole (a-z)", "txt_generator_numbers": "Numeri (0-9)", "txt_generator_special": "Caratteri speciali (!@#$%^&*)", "txt_generator_minimum": "Minimo", "txt_generator_avoid_ambiguous": "Evita caratteri ambigui", "txt_generator_words": "Numero di parole", "txt_generator_separator": "Separatore di parole", "txt_generator_capitalize": "Iniziale maiuscola", "txt_generator_include_number": "Includi un numero",
|
||||||
|
"txt_generated_value": "Valore generato", "txt_generator_character_count": "{count} caratteri", "txt_generator_pin": "Codice PIN", "txt_generator_pin_description": "Genera localmente un PIN numerico con casualità crittograficamente sicura.", "txt_generator_username": "Nome utente", "txt_generator_plus_addressed_email": "Email con indirizzamento plus", "txt_generator_catch_all_email": "Email catch-all", "txt_generator_email": "Indirizzo email", "txt_generator_domain": "Dominio",
|
||||||
|
"txt_generator_word_list": "Elenco parole", "txt_generator_eff_word_list": "Elenco lungo EFF", "txt_generator_custom_word_list": "Elenco personalizzato", "txt_generator_custom_words": "Parole personalizzate", "txt_generator_custom_words_placeholder": "Inserisci almeno due parole separate da spazi, virgole o righe",
|
||||||
|
"txt_generator_long_word_username": "Nome utente a parole lunghe", "txt_generator_custom_word": "Parola mnemonica personalizzata", "txt_generator_subdomain_email": "Email con sottodominio", "txt_generator_ssh_key": "Chiave SSH", "txt_generator_ssh_generating": "Generazione chiave SSH…", "txt_generator_ssh_error": "Questo browser non supporta la generazione di chiavi SSH.", "txt_generator_copy_public_key": "Copia chiave pubblica", "txt_generator_ssh_security_note": "La coppia viene generata localmente e mai inviata. Salva ora la chiave privata; NodeWarden non la conserva.", "txt_generator_public_key": "Chiave pubblica", "txt_generator_private_key": "Chiave privata — mantienila segreta", "txt_generator_ssh_algorithm": "Algoritmo", "txt_generator_key_length": "Lunghezza chiave", "txt_generator_ssh_comment": "Commento chiave pubblica", "txt_generator_ssh_rsa_description": "RSA è ampiamente compatibile. Usa almeno 2048 bit; 4096 sono consigliati.", "txt_generator_ssh_ed25519_description": "Ed25519 è veloce, compatto e consigliato per client SSH moderni.",
|
||||||
|
"txt_generator_email_alias": "Alias email", "txt_generator_email_type": "Tipo di alias", "txt_generator_email_required_hint": "Inserisci l’indirizzo email o il dominio per generare un alias.", "txt_generator_email_description": "I dati restano su questo dispositivo e servono solo a comporre l’alias.", "txt_generator_long_word_username_description": "Combina una o più parole lunghe in un nome utente facile da ricordare.",
|
||||||
"nav_backup_strategy": "Backup su Cloud",
|
"nav_backup_strategy": "Backup su Cloud",
|
||||||
"nav_import_export": "Importa ed Esporta",
|
"nav_import_export": "Importa ed Esporta",
|
||||||
"nav_group_data_backup": "Dati e Backup",
|
"nav_group_data_backup": "Dati e Backup",
|
||||||
@@ -511,6 +515,7 @@ const it: Record<string, string> = {
|
|||||||
"txt_create_account": "Crea Account",
|
"txt_create_account": "Crea Account",
|
||||||
"txt_registering": "Creazione Account in corso...",
|
"txt_registering": "Creazione Account in corso...",
|
||||||
"txt_register_failed": "Registrazione fallita",
|
"txt_register_failed": "Registrazione fallita",
|
||||||
|
"txt_web_crypto_unavailable": "La crittografia sicura del browser non è disponibile. Apri NodeWarden tramite HTTPS in un browser supportato.",
|
||||||
"txt_create_folder": "Crea cartella",
|
"txt_create_folder": "Crea cartella",
|
||||||
"txt_create_folder_failed": "Impossibile creare la cartella",
|
"txt_create_folder_failed": "Impossibile creare la cartella",
|
||||||
"txt_create_item_failed": "Impossibile creare l'elemento",
|
"txt_create_item_failed": "Impossibile creare l'elemento",
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ const ru: Record<string, string> = {
|
|||||||
"nav_vault_items": "Хранилище",
|
"nav_vault_items": "Хранилище",
|
||||||
"nav_sends": "Отправляет",
|
"nav_sends": "Отправляет",
|
||||||
"nav_generator": "Генератор", "txt_password_generator": "Генератор паролей", "txt_password_generator_description": "Создайте надежный уникальный пароль локально на этом устройстве.", "txt_generator_type": "Тип генератора", "txt_passphrase": "Парольная фраза", "txt_generated_password": "Созданный пароль", "txt_password_strength": "Надежность", "txt_password_strength_weak": "Слабый", "txt_password_strength_fair": "Средний", "txt_password_strength_good": "Хороший", "txt_password_strength_strong": "Надежный", "txt_generator_security_note": "Генерация выполняется локально. Пароль никогда не отправляется на сервер.", "txt_generator_length": "Длина", "txt_generator_character_types": "Типы символов", "txt_generator_uppercase": "Заглавные буквы (A-Z)", "txt_generator_lowercase": "Строчные буквы (a-z)", "txt_generator_numbers": "Цифры (0-9)", "txt_generator_special": "Специальные символы (!@#$%^&*)", "txt_generator_minimum": "Минимум", "txt_generator_avoid_ambiguous": "Исключить похожие символы", "txt_generator_words": "Количество слов", "txt_generator_separator": "Разделитель слов", "txt_generator_capitalize": "С заглавной буквы", "txt_generator_include_number": "Добавить число",
|
"nav_generator": "Генератор", "txt_password_generator": "Генератор паролей", "txt_password_generator_description": "Создайте надежный уникальный пароль локально на этом устройстве.", "txt_generator_type": "Тип генератора", "txt_passphrase": "Парольная фраза", "txt_generated_password": "Созданный пароль", "txt_password_strength": "Надежность", "txt_password_strength_weak": "Слабый", "txt_password_strength_fair": "Средний", "txt_password_strength_good": "Хороший", "txt_password_strength_strong": "Надежный", "txt_generator_security_note": "Генерация выполняется локально. Пароль никогда не отправляется на сервер.", "txt_generator_length": "Длина", "txt_generator_character_types": "Типы символов", "txt_generator_uppercase": "Заглавные буквы (A-Z)", "txt_generator_lowercase": "Строчные буквы (a-z)", "txt_generator_numbers": "Цифры (0-9)", "txt_generator_special": "Специальные символы (!@#$%^&*)", "txt_generator_minimum": "Минимум", "txt_generator_avoid_ambiguous": "Исключить похожие символы", "txt_generator_words": "Количество слов", "txt_generator_separator": "Разделитель слов", "txt_generator_capitalize": "С заглавной буквы", "txt_generator_include_number": "Добавить число",
|
||||||
|
"txt_generated_value": "Созданное значение", "txt_generator_character_count": "Символов: {count}", "txt_generator_pin": "PIN-код", "txt_generator_pin_description": "Создаёт числовой PIN локально с криптографически стойкой случайностью.", "txt_generator_username": "Имя пользователя", "txt_generator_plus_addressed_email": "Email с плюс-адресацией", "txt_generator_catch_all_email": "Catch-all email", "txt_generator_email": "Адрес электронной почты", "txt_generator_domain": "Домен",
|
||||||
|
"txt_generator_word_list": "Список слов", "txt_generator_eff_word_list": "Длинный список EFF", "txt_generator_custom_word_list": "Свой список слов", "txt_generator_custom_words": "Свои слова", "txt_generator_custom_words_placeholder": "Введите не менее двух слов через пробел, запятую или с новой строки",
|
||||||
|
"txt_generator_long_word_username": "Имя из длинных слов", "txt_generator_custom_word": "Своё мнемоническое слово", "txt_generator_subdomain_email": "Email с поддоменом", "txt_generator_ssh_key": "SSH-ключ", "txt_generator_ssh_generating": "Создание SSH-ключа…", "txt_generator_ssh_error": "Этот браузер не поддерживает создание SSH-ключей.", "txt_generator_copy_public_key": "Копировать открытый ключ", "txt_generator_ssh_security_note": "Пара создаётся локально и не отправляется. Сохраните закрытый ключ сейчас; NodeWarden его не хранит.", "txt_generator_public_key": "Открытый ключ", "txt_generator_private_key": "Закрытый ключ — храните в тайне", "txt_generator_ssh_algorithm": "Алгоритм", "txt_generator_key_length": "Длина ключа", "txt_generator_ssh_comment": "Комментарий открытого ключа", "txt_generator_ssh_rsa_description": "RSA широко совместим. Используйте минимум 2048 бит; рекомендуется 4096.", "txt_generator_ssh_ed25519_description": "Ed25519 быстр, компактен и рекомендуется для современных SSH-клиентов.",
|
||||||
|
"txt_generator_email_alias": "Почтовый псевдоним", "txt_generator_email_type": "Тип псевдонима", "txt_generator_email_required_hint": "Введите адрес электронной почты или домен, чтобы создать псевдоним.", "txt_generator_email_description": "Введённые данные остаются на устройстве и используются только для создания псевдонима.", "txt_generator_long_word_username_description": "Объедините одно или несколько длинных слов в запоминающееся имя пользователя.",
|
||||||
"nav_backup_strategy": "Облачное резервное копирование",
|
"nav_backup_strategy": "Облачное резервное копирование",
|
||||||
"nav_import_export": "Импорт и экспорт",
|
"nav_import_export": "Импорт и экспорт",
|
||||||
"nav_group_data_backup": "Данные и резервные копии",
|
"nav_group_data_backup": "Данные и резервные копии",
|
||||||
@@ -511,6 +515,7 @@ const ru: Record<string, string> = {
|
|||||||
"txt_create_account": "Создать учетную запись",
|
"txt_create_account": "Создать учетную запись",
|
||||||
"txt_registering": "Создание учетной записи...",
|
"txt_registering": "Создание учетной записи...",
|
||||||
"txt_register_failed": "Не удалось зарегистрироваться",
|
"txt_register_failed": "Не удалось зарегистрироваться",
|
||||||
|
"txt_web_crypto_unavailable": "Безопасная криптография браузера недоступна. Откройте NodeWarden по HTTPS в поддерживаемом браузере.",
|
||||||
"txt_create_folder": "Создать папку",
|
"txt_create_folder": "Создать папку",
|
||||||
"txt_create_folder_failed": "Создать папку не удалось",
|
"txt_create_folder_failed": "Создать папку не удалось",
|
||||||
"txt_create_item_failed": "Создать элемент не удалось",
|
"txt_create_item_failed": "Создать элемент не удалось",
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ const sv: Record<string, string> = {
|
|||||||
"nav_vault_items": "Valv",
|
"nav_vault_items": "Valv",
|
||||||
"nav_sends": "Skickat",
|
"nav_sends": "Skickat",
|
||||||
"nav_generator": "Generator", "txt_password_generator": "Lösenordsgenerator", "txt_password_generator_description": "Skapa ett starkt och unikt lösenord lokalt på den här enheten.", "txt_generator_type": "Generatortyp", "txt_passphrase": "Lösenfras", "txt_generated_password": "Genererat lösenord", "txt_password_strength": "Styrka", "txt_password_strength_weak": "Svagt", "txt_password_strength_fair": "Medel", "txt_password_strength_good": "Bra", "txt_password_strength_strong": "Starkt", "txt_generator_security_note": "Generering sker lokalt. Ditt lösenord skickas aldrig till servern.", "txt_generator_length": "Längd", "txt_generator_character_types": "Teckentyper", "txt_generator_uppercase": "Versaler (A-Z)", "txt_generator_lowercase": "Gemener (a-z)", "txt_generator_numbers": "Siffror (0-9)", "txt_generator_special": "Specialtecken (!@#$%^&*)", "txt_generator_minimum": "Minst", "txt_generator_avoid_ambiguous": "Undvik tvetydiga tecken", "txt_generator_words": "Antal ord", "txt_generator_separator": "Ordavgränsare", "txt_generator_capitalize": "Stor begynnelsebokstav", "txt_generator_include_number": "Inkludera en siffra",
|
"nav_generator": "Generator", "txt_password_generator": "Lösenordsgenerator", "txt_password_generator_description": "Skapa ett starkt och unikt lösenord lokalt på den här enheten.", "txt_generator_type": "Generatortyp", "txt_passphrase": "Lösenfras", "txt_generated_password": "Genererat lösenord", "txt_password_strength": "Styrka", "txt_password_strength_weak": "Svagt", "txt_password_strength_fair": "Medel", "txt_password_strength_good": "Bra", "txt_password_strength_strong": "Starkt", "txt_generator_security_note": "Generering sker lokalt. Ditt lösenord skickas aldrig till servern.", "txt_generator_length": "Längd", "txt_generator_character_types": "Teckentyper", "txt_generator_uppercase": "Versaler (A-Z)", "txt_generator_lowercase": "Gemener (a-z)", "txt_generator_numbers": "Siffror (0-9)", "txt_generator_special": "Specialtecken (!@#$%^&*)", "txt_generator_minimum": "Minst", "txt_generator_avoid_ambiguous": "Undvik tvetydiga tecken", "txt_generator_words": "Antal ord", "txt_generator_separator": "Ordavgränsare", "txt_generator_capitalize": "Stor begynnelsebokstav", "txt_generator_include_number": "Inkludera en siffra",
|
||||||
|
"txt_generated_value": "Genererat värde", "txt_generator_character_count": "{count} tecken", "txt_generator_pin": "PIN-kod", "txt_generator_pin_description": "Skapar en numerisk PIN lokalt med kryptografiskt säker slumpning.", "txt_generator_username": "Användarnamn", "txt_generator_plus_addressed_email": "Plusadresserad e-post", "txt_generator_catch_all_email": "Catch-all-e-post", "txt_generator_email": "E-postadress", "txt_generator_domain": "Domän",
|
||||||
|
"txt_generator_word_list": "Ordlista", "txt_generator_eff_word_list": "Lång EFF-ordlista", "txt_generator_custom_word_list": "Egen ordlista", "txt_generator_custom_words": "Egna ord", "txt_generator_custom_words_placeholder": "Ange minst två ord åtskilda med blanksteg, kommatecken eller rader",
|
||||||
|
"txt_generator_long_word_username": "Användarnamn med långa ord", "txt_generator_custom_word": "Eget minnesord", "txt_generator_subdomain_email": "E-post med underdomän", "txt_generator_ssh_key": "SSH-nyckel", "txt_generator_ssh_generating": "Skapar SSH-nyckel…", "txt_generator_ssh_error": "Webbläsaren stöder inte skapande av SSH-nycklar.", "txt_generator_copy_public_key": "Kopiera offentlig nyckel", "txt_generator_ssh_security_note": "Nyckelparet skapas lokalt och skickas aldrig. Spara den privata nyckeln nu; NodeWarden behåller den inte.", "txt_generator_public_key": "Offentlig nyckel", "txt_generator_private_key": "Privat nyckel — håll den hemlig", "txt_generator_ssh_algorithm": "Algoritm", "txt_generator_key_length": "Nyckellängd", "txt_generator_ssh_comment": "Kommentar för offentlig nyckel", "txt_generator_ssh_rsa_description": "RSA har bred kompatibilitet. Använd minst 2048 bitar; 4096 rekommenderas.", "txt_generator_ssh_ed25519_description": "Ed25519 är snabb, kompakt och rekommenderas för moderna SSH-klienter.",
|
||||||
|
"txt_generator_email_alias": "E-postalias", "txt_generator_email_type": "Aliastyp", "txt_generator_email_required_hint": "Ange din e-postadress eller domän för att skapa ett alias.", "txt_generator_email_description": "Inmatningen stannar på enheten och används endast för att skapa aliaset.", "txt_generator_long_word_username_description": "Kombinera ett eller flera långa ord till ett minnesvärt användarnamn.",
|
||||||
"nav_backup_strategy": "Molnsäkerhetskopiering",
|
"nav_backup_strategy": "Molnsäkerhetskopiering",
|
||||||
"nav_import_export": "Importera och Exportera",
|
"nav_import_export": "Importera och Exportera",
|
||||||
"nav_group_data_backup": "Data och Säkerhetskopiering",
|
"nav_group_data_backup": "Data och Säkerhetskopiering",
|
||||||
@@ -511,6 +515,7 @@ const sv: Record<string, string> = {
|
|||||||
"txt_create_account": "Skapa konto",
|
"txt_create_account": "Skapa konto",
|
||||||
"txt_registering": "Skapar konto...",
|
"txt_registering": "Skapar konto...",
|
||||||
"txt_register_failed": "Registrering misslyckades",
|
"txt_register_failed": "Registrering misslyckades",
|
||||||
|
"txt_web_crypto_unavailable": "Säker webbläsarkryptografi är inte tillgänglig. Öppna NodeWarden via HTTPS i en webbläsare som stöds.",
|
||||||
"txt_create_folder": "Skapa mapp",
|
"txt_create_folder": "Skapa mapp",
|
||||||
"txt_create_folder_failed": "Misslyckades med att skapa mapp",
|
"txt_create_folder_failed": "Misslyckades med att skapa mapp",
|
||||||
"txt_create_item_failed": "Misslyckades med att skapa objekt",
|
"txt_create_item_failed": "Misslyckades med att skapa objekt",
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_password_generator": "密码生成器",
|
"txt_password_generator": "密码生成器",
|
||||||
"txt_password_generator_description": "在此设备本地生成强且唯一的密码。",
|
"txt_password_generator_description": "在此设备本地生成强且唯一的密码。",
|
||||||
"txt_generator_type": "生成类型", "txt_passphrase": "密码短语", "txt_generated_password": "已生成密码", "txt_password_strength": "强度", "txt_password_strength_weak": "弱", "txt_password_strength_fair": "一般", "txt_password_strength_good": "良好", "txt_password_strength_strong": "强", "txt_generator_security_note": "生成过程仅在本地进行,密码不会发送到服务器。", "txt_generator_length": "长度", "txt_generator_character_types": "字符类型", "txt_generator_uppercase": "大写字母 (A-Z)", "txt_generator_lowercase": "小写字母 (a-z)", "txt_generator_numbers": "数字 (0-9)", "txt_generator_special": "特殊字符 (!@#$%^&*)", "txt_generator_minimum": "最少数量", "txt_generator_avoid_ambiguous": "避免易混淆字符", "txt_generator_words": "单词数量", "txt_generator_separator": "单词分隔符", "txt_generator_capitalize": "首字母大写", "txt_generator_include_number": "包含数字",
|
"txt_generator_type": "生成类型", "txt_passphrase": "密码短语", "txt_generated_password": "已生成密码", "txt_password_strength": "强度", "txt_password_strength_weak": "弱", "txt_password_strength_fair": "一般", "txt_password_strength_good": "良好", "txt_password_strength_strong": "强", "txt_generator_security_note": "生成过程仅在本地进行,密码不会发送到服务器。", "txt_generator_length": "长度", "txt_generator_character_types": "字符类型", "txt_generator_uppercase": "大写字母 (A-Z)", "txt_generator_lowercase": "小写字母 (a-z)", "txt_generator_numbers": "数字 (0-9)", "txt_generator_special": "特殊字符 (!@#$%^&*)", "txt_generator_minimum": "最少数量", "txt_generator_avoid_ambiguous": "避免易混淆字符", "txt_generator_words": "单词数量", "txt_generator_separator": "单词分隔符", "txt_generator_capitalize": "首字母大写", "txt_generator_include_number": "包含数字",
|
||||||
|
"txt_generated_value": "已生成内容", "txt_generator_character_count": "{count} 个字符", "txt_generator_pin": "PIN 码", "txt_generator_pin_description": "使用密码学安全随机数在本地生成纯数字 PIN 码。", "txt_generator_username": "用户名", "txt_generator_plus_addressed_email": "加号别名邮箱", "txt_generator_catch_all_email": "全域接收邮箱", "txt_generator_email": "邮箱地址", "txt_generator_domain": "域名",
|
||||||
|
"txt_generator_word_list": "词表", "txt_generator_eff_word_list": "EFF 长词表", "txt_generator_custom_word_list": "自定义词表", "txt_generator_custom_words": "自定义单词", "txt_generator_custom_words_placeholder": "输入至少两个单词,用空格、逗号或换行分隔",
|
||||||
|
"txt_generator_long_word_username": "长单词用户名", "txt_generator_custom_word": "自定义助记词", "txt_generator_subdomain_email": "子域电子邮件", "txt_generator_ssh_key": "SSH 密钥", "txt_generator_ssh_generating": "正在生成 SSH 密钥…", "txt_generator_ssh_error": "此浏览器不支持 SSH 密钥生成。", "txt_generator_copy_public_key": "复制公钥", "txt_generator_ssh_security_note": "密钥对仅在本地生成,不会发送到服务器。请立即保存私钥,NodeWarden 不会保留它。", "txt_generator_public_key": "公钥", "txt_generator_private_key": "私钥 — 请保密", "txt_generator_ssh_algorithm": "算法", "txt_generator_key_length": "密钥长度", "txt_generator_ssh_comment": "公钥注释", "txt_generator_ssh_rsa_description": "RSA 兼容性广泛。请至少使用 2048 位,推荐 4096 位。", "txt_generator_ssh_ed25519_description": "Ed25519 快速且紧凑,推荐用于现代 SSH 客户端。",
|
||||||
|
"txt_generator_email_alias": "邮箱别名", "txt_generator_email_type": "邮箱别名类型", "txt_generator_email_required_hint": "输入邮箱地址或域名后,即可生成别名。", "txt_generator_email_description": "输入内容只保留在此设备上,仅用于组合邮箱别名。", "txt_generator_long_word_username_description": "将一个或多个长单词组合成易记的用户名。",
|
||||||
"nav_backup_strategy": "云端备份",
|
"nav_backup_strategy": "云端备份",
|
||||||
"nav_import_export": "导入导出",
|
"nav_import_export": "导入导出",
|
||||||
"nav_group_data_backup": "数据与备份",
|
"nav_group_data_backup": "数据与备份",
|
||||||
@@ -514,6 +518,7 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_create_account": "创建账户",
|
"txt_create_account": "创建账户",
|
||||||
"txt_registering": "正在注册...",
|
"txt_registering": "正在注册...",
|
||||||
"txt_register_failed": "注册失败",
|
"txt_register_failed": "注册失败",
|
||||||
|
"txt_web_crypto_unavailable": "当前浏览器环境无法使用安全加密。请通过 HTTPS 打开 NodeWarden,并使用受支持的现代浏览器。",
|
||||||
"txt_create_folder": "创建文件夹",
|
"txt_create_folder": "创建文件夹",
|
||||||
"txt_create_folder_failed": "创建文件夹失败",
|
"txt_create_folder_failed": "创建文件夹失败",
|
||||||
"txt_create_item_failed": "创建项目失败",
|
"txt_create_item_failed": "创建项目失败",
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_password_generator": "密碼產生器",
|
"txt_password_generator": "密碼產生器",
|
||||||
"txt_password_generator_description": "在此裝置本機建立強而唯一的密碼。",
|
"txt_password_generator_description": "在此裝置本機建立強而唯一的密碼。",
|
||||||
"txt_generator_type": "產生類型", "txt_passphrase": "密碼片語", "txt_generated_password": "已產生密碼", "txt_password_strength": "強度", "txt_password_strength_weak": "弱", "txt_password_strength_fair": "普通", "txt_password_strength_good": "良好", "txt_password_strength_strong": "強", "txt_generator_security_note": "產生程序僅在本機進行,密碼不會傳送到伺服器。", "txt_generator_length": "長度", "txt_generator_character_types": "字元類型", "txt_generator_uppercase": "大寫字母 (A-Z)", "txt_generator_lowercase": "小寫字母 (a-z)", "txt_generator_numbers": "數字 (0-9)", "txt_generator_special": "特殊字元 (!@#$%^&*)", "txt_generator_minimum": "最少數量", "txt_generator_avoid_ambiguous": "避免易混淆字元", "txt_generator_words": "單字數量", "txt_generator_separator": "單字分隔符號", "txt_generator_capitalize": "首字母大寫", "txt_generator_include_number": "包含數字",
|
"txt_generator_type": "產生類型", "txt_passphrase": "密碼片語", "txt_generated_password": "已產生密碼", "txt_password_strength": "強度", "txt_password_strength_weak": "弱", "txt_password_strength_fair": "普通", "txt_password_strength_good": "良好", "txt_password_strength_strong": "強", "txt_generator_security_note": "產生程序僅在本機進行,密碼不會傳送到伺服器。", "txt_generator_length": "長度", "txt_generator_character_types": "字元類型", "txt_generator_uppercase": "大寫字母 (A-Z)", "txt_generator_lowercase": "小寫字母 (a-z)", "txt_generator_numbers": "數字 (0-9)", "txt_generator_special": "特殊字元 (!@#$%^&*)", "txt_generator_minimum": "最少數量", "txt_generator_avoid_ambiguous": "避免易混淆字元", "txt_generator_words": "單字數量", "txt_generator_separator": "單字分隔符號", "txt_generator_capitalize": "首字母大寫", "txt_generator_include_number": "包含數字",
|
||||||
|
"txt_generated_value": "已產生內容", "txt_generator_character_count": "{count} 個字元", "txt_generator_pin": "PIN 碼", "txt_generator_pin_description": "使用密碼學安全亂數在本機產生純數字 PIN 碼。", "txt_generator_username": "使用者名稱", "txt_generator_plus_addressed_email": "加號別名信箱", "txt_generator_catch_all_email": "全域接收信箱", "txt_generator_email": "電子郵件地址", "txt_generator_domain": "網域",
|
||||||
|
"txt_generator_word_list": "詞表", "txt_generator_eff_word_list": "EFF 長詞表", "txt_generator_custom_word_list": "自訂詞表", "txt_generator_custom_words": "自訂單字", "txt_generator_custom_words_placeholder": "輸入至少兩個單字,以空格、逗號或換行分隔",
|
||||||
|
"txt_generator_long_word_username": "長單字使用者名稱", "txt_generator_custom_word": "自訂助記詞", "txt_generator_subdomain_email": "子網域電子郵件", "txt_generator_ssh_key": "SSH 金鑰", "txt_generator_ssh_generating": "正在產生 SSH 金鑰…", "txt_generator_ssh_error": "此瀏覽器不支援 SSH 金鑰產生。", "txt_generator_copy_public_key": "複製公鑰", "txt_generator_ssh_security_note": "金鑰組只在本機產生,不會傳送到伺服器。請立即儲存私鑰,NodeWarden 不會保留它。", "txt_generator_public_key": "公鑰", "txt_generator_private_key": "私鑰 — 請保密", "txt_generator_ssh_algorithm": "演算法", "txt_generator_key_length": "金鑰長度", "txt_generator_ssh_comment": "公鑰註解", "txt_generator_ssh_rsa_description": "RSA 相容性廣泛。請至少使用 2048 位元,建議 4096 位元。", "txt_generator_ssh_ed25519_description": "Ed25519 快速且精簡,建議用於現代 SSH 用戶端。",
|
||||||
|
"txt_generator_email_alias": "信箱別名", "txt_generator_email_type": "信箱別名類型", "txt_generator_email_required_hint": "輸入電子郵件地址或網域後,即可產生別名。", "txt_generator_email_description": "輸入內容只保留在此裝置上,僅用於組合信箱別名。", "txt_generator_long_word_username_description": "將一個或多個長單字組合成容易記住的使用者名稱。",
|
||||||
"nav_backup_strategy": "雲端備份",
|
"nav_backup_strategy": "雲端備份",
|
||||||
"nav_import_export": "導入導出",
|
"nav_import_export": "導入導出",
|
||||||
"nav_group_data_backup": "資料與備份",
|
"nav_group_data_backup": "資料與備份",
|
||||||
@@ -514,6 +518,7 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_create_account": "創建賬戶",
|
"txt_create_account": "創建賬戶",
|
||||||
"txt_registering": "正在註冊...",
|
"txt_registering": "正在註冊...",
|
||||||
"txt_register_failed": "註冊失敗",
|
"txt_register_failed": "註冊失敗",
|
||||||
|
"txt_web_crypto_unavailable": "目前瀏覽器環境無法使用安全加密。請透過 HTTPS 開啟 NodeWarden,並使用受支援的現代瀏覽器。",
|
||||||
"txt_create_folder": "創建文件夾",
|
"txt_create_folder": "創建文件夾",
|
||||||
"txt_create_folder_failed": "創建文件夾失敗",
|
"txt_create_folder_failed": "創建文件夾失敗",
|
||||||
"txt_create_item_failed": "創建項目失敗",
|
"txt_create_item_failed": "創建項目失敗",
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import { EFFLongWordList } from '@/lib/eff-word-list';
|
||||||
|
|
||||||
|
export type GeneratorMode = 'password' | 'passphrase' | 'pin' | 'username' | 'email' | 'sshKey';
|
||||||
|
export type EmailMode = 'plusAddressed' | 'catchAll' | 'subdomain';
|
||||||
|
|
||||||
|
export interface PasswordOptions {
|
||||||
|
length: number;
|
||||||
|
uppercase: boolean;
|
||||||
|
lowercase: boolean;
|
||||||
|
numbers: boolean;
|
||||||
|
special: boolean;
|
||||||
|
minUppercase: number;
|
||||||
|
minLowercase: number;
|
||||||
|
minNumbers: number;
|
||||||
|
minSpecial: number;
|
||||||
|
avoidAmbiguous: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PassphraseOptions {
|
||||||
|
words: number;
|
||||||
|
separator: string;
|
||||||
|
capitalize: boolean;
|
||||||
|
includeNumber: boolean;
|
||||||
|
wordList: 'eff' | 'custom';
|
||||||
|
customWords: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PinOptions { length: number }
|
||||||
|
|
||||||
|
export interface UsernameOptions {
|
||||||
|
words: number;
|
||||||
|
delimiter: string;
|
||||||
|
capitalize: boolean;
|
||||||
|
includeNumber: boolean;
|
||||||
|
customWord: string;
|
||||||
|
wordList: 'eff' | 'custom';
|
||||||
|
customWords: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailOptions {
|
||||||
|
type: EmailMode;
|
||||||
|
email: string;
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SshKeyOptions {
|
||||||
|
type: 'ed25519' | 'rsa';
|
||||||
|
rsaLength: 2048 | 3072 | 4096;
|
||||||
|
comment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeneratorSettings {
|
||||||
|
mode: GeneratorMode;
|
||||||
|
password: PasswordOptions;
|
||||||
|
passphrase: PassphraseOptions;
|
||||||
|
pin: PinOptions;
|
||||||
|
username: UsernameOptions;
|
||||||
|
email: EmailOptions;
|
||||||
|
sshKey: SshKeyOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
const LOWERCASE = 'abcdefghijklmnopqrstuvwxyz';
|
||||||
|
const DIGITS = '0123456789';
|
||||||
|
const SPECIAL = '!@#$%^&*_-+=:;,.?~';
|
||||||
|
const AMBIGUOUS = new Set(['I', 'L', 'O', 'l', 'o', '0', '1', '|']);
|
||||||
|
|
||||||
|
export const defaultGeneratorSettings: GeneratorSettings = {
|
||||||
|
mode: 'password',
|
||||||
|
password: {
|
||||||
|
length: 16,
|
||||||
|
uppercase: true,
|
||||||
|
lowercase: true,
|
||||||
|
numbers: true,
|
||||||
|
special: false,
|
||||||
|
minUppercase: 1,
|
||||||
|
minLowercase: 1,
|
||||||
|
minNumbers: 1,
|
||||||
|
minSpecial: 1,
|
||||||
|
avoidAmbiguous: false,
|
||||||
|
},
|
||||||
|
passphrase: { words: 6, separator: '-', capitalize: false, includeNumber: false, wordList: 'eff', customWords: '' },
|
||||||
|
pin: { length: 6 },
|
||||||
|
username: { words: 2, delimiter: '', capitalize: true, includeNumber: true, customWord: '', wordList: 'eff', customWords: '' },
|
||||||
|
email: { type: 'plusAddressed', email: '', domain: '' },
|
||||||
|
sshKey: { type: 'ed25519', rsaLength: 4096, comment: '' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function clampInteger(value: unknown, minimum: number, maximum: number, fallback: number): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? Math.min(maximum, Math.max(minimum, Math.round(parsed))) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeGeneratorSettings(value: unknown): GeneratorSettings {
|
||||||
|
const stored = value && typeof value === 'object' ? value as Partial<GeneratorSettings> : {};
|
||||||
|
const password: Partial<PasswordOptions> = stored.password && typeof stored.password === 'object' ? stored.password : {};
|
||||||
|
const passphrase: Partial<PassphraseOptions> = stored.passphrase && typeof stored.passphrase === 'object' ? stored.passphrase : {};
|
||||||
|
const pin: Partial<PinOptions> = stored.pin && typeof stored.pin === 'object' ? stored.pin : {};
|
||||||
|
const username: Partial<UsernameOptions> = stored.username && typeof stored.username === 'object' ? stored.username : {};
|
||||||
|
const legacyUsername = stored.username && typeof stored.username === 'object' ? stored.username as Partial<UsernameOptions> & Partial<EmailOptions> : {};
|
||||||
|
const email: Partial<EmailOptions> = stored.email && typeof stored.email === 'object' ? stored.email : legacyUsername;
|
||||||
|
const sshKey: Partial<SshKeyOptions> = stored.sshKey && typeof stored.sshKey === 'object' ? stored.sshKey : {};
|
||||||
|
const modes: GeneratorMode[] = ['password', 'passphrase', 'pin', 'username', 'email', 'sshKey'];
|
||||||
|
const emailModes: EmailMode[] = ['plusAddressed', 'catchAll', 'subdomain'];
|
||||||
|
const rsaLengths: SshKeyOptions['rsaLength'][] = [2048, 3072, 4096];
|
||||||
|
const mode = stored.mode === 'username' && emailModes.includes(legacyUsername.type as EmailMode)
|
||||||
|
? 'email'
|
||||||
|
: modes.includes(stored.mode as GeneratorMode) ? stored.mode as GeneratorMode : defaultGeneratorSettings.mode;
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
password: {
|
||||||
|
...defaultGeneratorSettings.password,
|
||||||
|
...password,
|
||||||
|
length: clampInteger(password.length, 5, 128, defaultGeneratorSettings.password.length),
|
||||||
|
minUppercase: clampInteger(password.minUppercase, 0, 9, defaultGeneratorSettings.password.minUppercase),
|
||||||
|
minLowercase: clampInteger(password.minLowercase, 0, 9, defaultGeneratorSettings.password.minLowercase),
|
||||||
|
minNumbers: clampInteger(password.minNumbers, 0, 9, defaultGeneratorSettings.password.minNumbers),
|
||||||
|
minSpecial: clampInteger(password.minSpecial, 0, 9, defaultGeneratorSettings.password.minSpecial),
|
||||||
|
},
|
||||||
|
passphrase: {
|
||||||
|
...defaultGeneratorSettings.passphrase,
|
||||||
|
...passphrase,
|
||||||
|
words: clampInteger(passphrase.words, 3, 20, defaultGeneratorSettings.passphrase.words),
|
||||||
|
separator: String(passphrase.separator ?? defaultGeneratorSettings.passphrase.separator).slice(0, 1),
|
||||||
|
wordList: passphrase.wordList === 'custom' ? 'custom' : 'eff',
|
||||||
|
customWords: String(passphrase.customWords ?? '').slice(0, 50_000),
|
||||||
|
},
|
||||||
|
pin: { length: clampInteger(pin.length, 3, 64, defaultGeneratorSettings.pin.length) },
|
||||||
|
username: {
|
||||||
|
...defaultGeneratorSettings.username,
|
||||||
|
...username,
|
||||||
|
words: clampInteger(username.words, 1, 10, defaultGeneratorSettings.username.words),
|
||||||
|
delimiter: String(username.delimiter ?? defaultGeneratorSettings.username.delimiter).slice(0, 8),
|
||||||
|
customWord: String(username.customWord ?? '').trim().slice(0, 128),
|
||||||
|
wordList: username.wordList === 'custom' ? 'custom' : 'eff',
|
||||||
|
customWords: String(username.customWords ?? '').slice(0, 50_000),
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: emailModes.includes(email.type as EmailMode) ? email.type as EmailMode : defaultGeneratorSettings.email.type,
|
||||||
|
email: String(email.email ?? '').trim().slice(0, 254),
|
||||||
|
domain: String(email.domain ?? '').trim().slice(0, 253),
|
||||||
|
},
|
||||||
|
sshKey: {
|
||||||
|
type: sshKey.type === 'rsa' ? 'rsa' : 'ed25519',
|
||||||
|
rsaLength: rsaLengths.includes(sshKey.rsaLength as SshKeyOptions['rsaLength']) ? sshKey.rsaLength as SshKeyOptions['rsaLength'] : defaultGeneratorSettings.sshKey.rsaLength,
|
||||||
|
comment: String(sshKey.comment ?? '').replace(/[\r\n]+/g, ' ').trim().slice(0, 256),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomIndex(length: number): number {
|
||||||
|
if (!Number.isSafeInteger(length) || length < 1) throw new RangeError('Random source must not be empty');
|
||||||
|
const range = 0x1_0000_0000;
|
||||||
|
const upperBound = Math.floor(range / length) * length;
|
||||||
|
const buffer = new Uint32Array(1);
|
||||||
|
do crypto.getRandomValues(buffer); while (buffer[0] >= upperBound);
|
||||||
|
return buffer[0] % length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(characters: string): string { return characters[randomIndex(characters.length)]; }
|
||||||
|
|
||||||
|
function shuffle(value: string[]): string[] {
|
||||||
|
for (let index = value.length - 1; index > 0; index -= 1) {
|
||||||
|
const next = randomIndex(index + 1);
|
||||||
|
[value[index], value[next]] = [value[next], value[index]];
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filtered(characters: string, avoidAmbiguous: boolean): string {
|
||||||
|
return avoidAmbiguous ? [...characters].filter((character) => !AMBIGUOUS.has(character)).join('') : characters;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generatePassword(options: PasswordOptions): string {
|
||||||
|
const sets: Array<{ chars: string; minimum: number }> = [];
|
||||||
|
if (options.uppercase) sets.push({ chars: filtered(UPPERCASE, options.avoidAmbiguous), minimum: options.minUppercase });
|
||||||
|
if (options.lowercase) sets.push({ chars: filtered(LOWERCASE, options.avoidAmbiguous), minimum: options.minLowercase });
|
||||||
|
if (options.numbers) sets.push({ chars: filtered(DIGITS, options.avoidAmbiguous), minimum: options.minNumbers });
|
||||||
|
if (options.special) sets.push({ chars: SPECIAL, minimum: options.minSpecial });
|
||||||
|
if (!sets.length) sets.push({ chars: LOWERCASE, minimum: 1 });
|
||||||
|
|
||||||
|
const required = sets.reduce((total, set) => total + set.minimum, 0);
|
||||||
|
const length = Math.max(options.length, required, 5);
|
||||||
|
const allCharacters = sets.map((set) => set.chars).join('');
|
||||||
|
const result = sets.flatMap((set) => Array.from({ length: set.minimum }, () => pick(set.chars)));
|
||||||
|
while (result.length < length) result.push(pick(allCharacters));
|
||||||
|
return shuffle(result).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generatePassphrase(options: PassphraseOptions): string {
|
||||||
|
const customWords = [...new Set(options.customWords.split(/[\s,;]+/).map((word) => word.trim()).filter(Boolean))];
|
||||||
|
const wordList = options.wordList === 'custom' && customWords.length >= 2 ? customWords : EFFLongWordList;
|
||||||
|
const words = Array.from({ length: options.words }, () => wordList[randomIndex(wordList.length)]);
|
||||||
|
if (options.capitalize) {
|
||||||
|
for (let index = 0; index < words.length; index += 1) words[index] = words[index][0].toUpperCase() + words[index].slice(1);
|
||||||
|
}
|
||||||
|
if (options.includeNumber) words[randomIndex(words.length)] += String(randomIndex(10));
|
||||||
|
return words.join(options.separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generatePin(options: PinOptions): string {
|
||||||
|
return Array.from({ length: options.length }, () => pick(DIGITS)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomWord(options: Pick<UsernameOptions, 'words' | 'delimiter' | 'capitalize' | 'includeNumber' | 'customWord' | 'wordList' | 'customWords'>): string {
|
||||||
|
const customWords = [...new Set(options.customWords.split(/[\s,;]+/).map((word) => word.trim()).filter(Boolean))];
|
||||||
|
const wordList = options.wordList === 'custom' && customWords.length >= 2 ? customWords : EFFLongWordList;
|
||||||
|
const words = Array.from({ length: options.words }, () => wordList[randomIndex(wordList.length)]);
|
||||||
|
if (options.customWord) words[randomIndex(words.length)] = options.customWord;
|
||||||
|
if (options.capitalize) {
|
||||||
|
for (let index = 0; index < words.length; index += 1) words[index] = words[index][0].toUpperCase() + words[index].slice(1);
|
||||||
|
}
|
||||||
|
if (options.includeNumber) {
|
||||||
|
const digits = options.words === 1 ? 4 : options.words === 2 ? 3 : 2;
|
||||||
|
const minimum = 10 ** (digits - 1);
|
||||||
|
words[randomIndex(words.length)] += String(minimum + randomIndex(9 * minimum));
|
||||||
|
}
|
||||||
|
return words.join(options.delimiter);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDomain(value: string): string {
|
||||||
|
return value.trim().toLowerCase().replace(/^https?:\/\//, '').split('/')[0].replace(/^@/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateUsername(options: UsernameOptions): string {
|
||||||
|
return randomWord(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validDomain(value: string): string | null {
|
||||||
|
const domain = normalizeDomain(value);
|
||||||
|
return /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(domain) ? domain : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validEmail(value: string): { local: string; domain: string } | null {
|
||||||
|
const match = value.trim().match(/^([^\s@+]+)(?:\+[^\s@]*)?@([^\s@]+)$/);
|
||||||
|
const domain = match ? validDomain(match[2]) : null;
|
||||||
|
return match && domain ? { local: match[1], domain } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateEmail(options: EmailOptions): string {
|
||||||
|
const suffix = randomWord({ ...defaultGeneratorSettings.username, words: 1, capitalize: false, includeNumber: true });
|
||||||
|
if (options.type === 'catchAll') {
|
||||||
|
const domain = validDomain(options.domain);
|
||||||
|
return domain ? `${suffix}@${domain}` : '';
|
||||||
|
}
|
||||||
|
const email = validEmail(options.email);
|
||||||
|
if (!email) return '';
|
||||||
|
return options.type === 'subdomain'
|
||||||
|
? `${email.local}@${suffix}.${email.domain}`
|
||||||
|
: `${email.local}+${suffix}@${email.domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateValue(settings: GeneratorSettings): string {
|
||||||
|
if (settings.mode === 'passphrase') return generatePassphrase(settings.passphrase);
|
||||||
|
if (settings.mode === 'pin') return generatePin(settings.pin);
|
||||||
|
if (settings.mode === 'username') return generateUsername(settings.username);
|
||||||
|
if (settings.mode === 'email') return generateEmail(settings.email);
|
||||||
|
if (settings.mode === 'sshKey') return '';
|
||||||
|
return generatePassword(settings.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateStrength(mode: GeneratorMode, value: string, passphraseWordCount?: number): number {
|
||||||
|
if (mode === 'username' || mode === 'email' || mode === 'sshKey') return 0;
|
||||||
|
if (mode === 'pin') return value.length >= 10 ? 4 : value.length >= 8 ? 3 : value.length >= 6 ? 2 : 1;
|
||||||
|
if (mode === 'passphrase') {
|
||||||
|
const words = passphraseWordCount ?? value.split(/[-_. ]/).filter(Boolean).length;
|
||||||
|
return Math.min(4, Math.max(1, Math.floor(words / 2)));
|
||||||
|
}
|
||||||
|
return Math.min(4, Math.max(1, Math.floor(value.length / 5)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import type { SshKeyOptions } from '@/lib/password-generator';
|
||||||
|
|
||||||
|
export interface GeneratedSshKey {
|
||||||
|
type: 'ED25519' | 'RSA';
|
||||||
|
bits: number;
|
||||||
|
publicKey: string;
|
||||||
|
privateKey: string;
|
||||||
|
fingerprint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
function concat(...chunks: Uint8Array[]): Uint8Array {
|
||||||
|
const output = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0));
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
output.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uint32(value: number): Uint8Array {
|
||||||
|
const output = new Uint8Array(4);
|
||||||
|
new DataView(output.buffer).setUint32(0, value, false);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sshString(value: string | Uint8Array): Uint8Array {
|
||||||
|
const bytes = typeof value === 'string' ? encoder.encode(value) : value;
|
||||||
|
return concat(uint32(bytes.length), bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlBytes(value: string): Uint8Array {
|
||||||
|
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const decoded = atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='));
|
||||||
|
return Uint8Array.from(decoded, (character) => character.charCodeAt(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64(bytes: Uint8Array): string {
|
||||||
|
let binary = '';
|
||||||
|
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||||
|
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mpint(value: Uint8Array): Uint8Array {
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < value.length - 1 && value[offset] === 0) offset += 1;
|
||||||
|
let bytes = value.subarray(offset);
|
||||||
|
if (bytes[0] & 0x80) bytes = concat(new Uint8Array([0]), bytes);
|
||||||
|
return sshString(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSshPrivateKey(publicBlob: Uint8Array, fields: Uint8Array[]): string {
|
||||||
|
const check = new Uint32Array(1);
|
||||||
|
crypto.getRandomValues(check);
|
||||||
|
let privateBlock = concat(uint32(check[0]), uint32(check[0]), ...fields, sshString(''));
|
||||||
|
const paddingLength = 8 - (privateBlock.length % 8);
|
||||||
|
privateBlock = concat(privateBlock, Uint8Array.from({ length: paddingLength }, (_, index) => index + 1));
|
||||||
|
const envelope = concat(
|
||||||
|
encoder.encode('openssh-key-v1\0'),
|
||||||
|
sshString('none'),
|
||||||
|
sshString('none'),
|
||||||
|
sshString(new Uint8Array()),
|
||||||
|
uint32(1),
|
||||||
|
sshString(publicBlob),
|
||||||
|
sshString(privateBlock),
|
||||||
|
);
|
||||||
|
const body = base64(envelope).match(/.{1,70}/g)?.join('\n') || '';
|
||||||
|
return `-----BEGIN OPENSSH PRIVATE KEY-----\n${body}\n-----END OPENSSH PRIVATE KEY-----\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fingerprint(publicBlob: Uint8Array): Promise<string> {
|
||||||
|
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', Uint8Array.from(publicBlob).buffer));
|
||||||
|
return `SHA256:${base64(digest).replace(/=+$/, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function required(jwk: JsonWebKey, property: keyof JsonWebKey): Uint8Array {
|
||||||
|
const value = jwk[property];
|
||||||
|
if (typeof value !== 'string' || !value) throw new Error(`The generated key is missing ${String(property)}`);
|
||||||
|
return base64UrlBytes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateEd25519(comment: string): Promise<GeneratedSshKey> {
|
||||||
|
const pair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']) as CryptoKeyPair;
|
||||||
|
const [privateJwk, publicJwk] = await Promise.all([
|
||||||
|
crypto.subtle.exportKey('jwk', pair.privateKey),
|
||||||
|
crypto.subtle.exportKey('jwk', pair.publicKey),
|
||||||
|
]);
|
||||||
|
const publicBytes = required(publicJwk, 'x');
|
||||||
|
const seed = required(privateJwk, 'd');
|
||||||
|
const publicBlob = concat(sshString('ssh-ed25519'), sshString(publicBytes));
|
||||||
|
const privateKey = openSshPrivateKey(publicBlob, [
|
||||||
|
sshString('ssh-ed25519'),
|
||||||
|
sshString(publicBytes),
|
||||||
|
sshString(concat(seed, publicBytes)),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
type: 'ED25519',
|
||||||
|
bits: 256,
|
||||||
|
publicKey: `ssh-ed25519 ${base64(publicBlob)}${comment ? ` ${comment}` : ''}`,
|
||||||
|
privateKey,
|
||||||
|
fingerprint: await fingerprint(publicBlob),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateRsa(length: SshKeyOptions['rsaLength'], comment: string): Promise<GeneratedSshKey> {
|
||||||
|
const pair = await crypto.subtle.generateKey({
|
||||||
|
name: 'RSASSA-PKCS1-v1_5',
|
||||||
|
modulusLength: length,
|
||||||
|
publicExponent: new Uint8Array([1, 0, 1]),
|
||||||
|
hash: 'SHA-256',
|
||||||
|
}, true, ['sign', 'verify']) as CryptoKeyPair;
|
||||||
|
const privateJwk = await crypto.subtle.exportKey('jwk', pair.privateKey);
|
||||||
|
const n = required(privateJwk, 'n');
|
||||||
|
const e = required(privateJwk, 'e');
|
||||||
|
const d = required(privateJwk, 'd');
|
||||||
|
const qi = required(privateJwk, 'qi');
|
||||||
|
const p = required(privateJwk, 'p');
|
||||||
|
const q = required(privateJwk, 'q');
|
||||||
|
const publicBlob = concat(sshString('ssh-rsa'), mpint(e), mpint(n));
|
||||||
|
const privateKey = openSshPrivateKey(publicBlob, [
|
||||||
|
sshString('ssh-rsa'),
|
||||||
|
mpint(n), mpint(e), mpint(d), mpint(qi), mpint(p), mpint(q),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
type: 'RSA',
|
||||||
|
bits: length,
|
||||||
|
publicKey: `ssh-rsa ${base64(publicBlob)}${comment ? ` ${comment}` : ''}`,
|
||||||
|
privateKey,
|
||||||
|
fingerprint: await fingerprint(publicBlob),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateSshKey(options: SshKeyOptions): Promise<GeneratedSshKey> {
|
||||||
|
if (!crypto?.subtle) throw new Error('Web Crypto is unavailable');
|
||||||
|
const comment = options.comment.replace(/[\r\n]+/g, ' ').trim();
|
||||||
|
return options.type === 'rsa' ? generateRsa(options.rsaLength, comment) : generateEd25519(comment);
|
||||||
|
}
|
||||||
@@ -10,8 +10,10 @@
|
|||||||
.generator-options-card { grid-area: options; padding: 19px; }
|
.generator-options-card { grid-area: options; padding: 19px; }
|
||||||
.generator-options-card h2 { margin: 0 0 18px; font-size: 17px; }
|
.generator-options-card h2 { margin: 0 0 18px; font-size: 17px; }
|
||||||
.generator-value { display: block; min-height: 110px; margin: 18px 0 10px; padding: 18px; border: 1px solid color-mix(in srgb, var(--primary) 22%, var(--line)); border-radius: 16px; background: color-mix(in srgb, var(--primary) 5%, var(--panel)); color: var(--text); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: clamp(19px, 2.2vw, 27px); font-weight: 700; line-height: 1.45; overflow-wrap: anywhere; user-select: all; }
|
.generator-value { display: block; min-height: 110px; margin: 18px 0 10px; padding: 18px; border: 1px solid color-mix(in srgb, var(--primary) 22%, var(--line)); border-radius: 16px; background: color-mix(in srgb, var(--primary) 5%, var(--panel)); color: var(--text); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: clamp(19px, 2.2vw, 27px); font-weight: 700; line-height: 1.45; overflow-wrap: anywhere; user-select: all; }
|
||||||
.generator-strength-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted-strong); font-size: 13px; font-weight: 700; }
|
.generator-value.empty { display: grid; place-items: center; color: var(--muted); font-family: inherit; font-size: 14px; font-weight: 500; text-align: center; user-select: none; }
|
||||||
.generator-strength-row > span, .generator-security-note { display: inline-flex; align-items: center; gap: 6px; }
|
.generator-mode-tabs { flex-wrap: wrap; }
|
||||||
|
.generator-meta-row { display: grid; grid-template-columns: minmax(90px, 1fr) auto auto; align-items: center; gap: 12px; color: var(--muted-strong); font-size: 13px; font-weight: 700; }
|
||||||
|
.generator-meta-row > span, .generator-security-note { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||||
.generator-strength { display: flex; flex: 1; gap: 4px; }
|
.generator-strength { display: flex; flex: 1; gap: 4px; }
|
||||||
.generator-strength span { height: 5px; flex: 1; border-radius: 999px; background: var(--line); }
|
.generator-strength span { height: 5px; flex: 1; border-radius: 999px; background: var(--line); }
|
||||||
.generator-strength span.active.level-1 { background: #e87171; }.generator-strength span.active.level-2 { background: #db9b38; }.generator-strength span.active.level-3 { background: #46936c; }.generator-strength span.active.level-4 { background: var(--primary); }
|
.generator-strength span.active.level-1 { background: #e87171; }.generator-strength span.active.level-2 { background: #db9b38; }.generator-strength span.active.level-3 { background: #46936c; }.generator-strength span.active.level-4 { background: var(--primary); }
|
||||||
@@ -20,6 +22,11 @@
|
|||||||
.generator-number-field { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 14px; margin-bottom: 15px; color: var(--text); font-size: 14px; font-weight: 700; }.generator-number-field > label { min-width: 0; }.generator-stepper { display: grid; grid-template-columns: 38px 64px 38px; align-items: center; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }.generator-stepper button { display: grid; width: 38px; height: 40px; place-items: center; border: 0; background: transparent; color: var(--primary-strong); cursor: pointer; transition: background-color 160ms ease, color 160ms ease; }.generator-stepper button:hover:not(:disabled) { background: color-mix(in srgb, var(--primary) 10%, var(--panel)); }.generator-stepper button:active:not(:disabled) { background: color-mix(in srgb, var(--primary) 17%, var(--panel)); }.generator-stepper button:focus-visible { position: relative; z-index: 1; outline: 3px solid color-mix(in srgb, var(--primary) 35%, transparent); outline-offset: -3px; }.generator-stepper button:disabled { color: var(--muted); cursor: not-allowed; }.generator-stepper .input { width: 64px; height: 40px; min-width: 0; border: 0; border-radius: 0; padding: 0; background: transparent; text-align: center; font-variant-numeric: tabular-nums; }.generator-stepper .input:focus { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--primary) 36%, transparent); }.generator-number-field.compact { grid-template-columns: minmax(0, 1fr) auto; margin: -3px 0 2px 50px; color: var(--muted); font-size: 13px; }.generator-number-field.compact .generator-stepper { grid-template-columns: 32px 46px 32px; border-radius: 9px; }.generator-number-field.compact .generator-stepper button { width: 32px; height: 34px; }.generator-number-field.compact .generator-stepper .input { width: 46px; height: 34px; font-size: 13px; }
|
.generator-number-field { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 14px; margin-bottom: 15px; color: var(--text); font-size: 14px; font-weight: 700; }.generator-number-field > label { min-width: 0; }.generator-stepper { display: grid; grid-template-columns: 38px 64px 38px; align-items: center; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }.generator-stepper button { display: grid; width: 38px; height: 40px; place-items: center; border: 0; background: transparent; color: var(--primary-strong); cursor: pointer; transition: background-color 160ms ease, color 160ms ease; }.generator-stepper button:hover:not(:disabled) { background: color-mix(in srgb, var(--primary) 10%, var(--panel)); }.generator-stepper button:active:not(:disabled) { background: color-mix(in srgb, var(--primary) 17%, var(--panel)); }.generator-stepper button:focus-visible { position: relative; z-index: 1; outline: 3px solid color-mix(in srgb, var(--primary) 35%, transparent); outline-offset: -3px; }.generator-stepper button:disabled { color: var(--muted); cursor: not-allowed; }.generator-stepper .input { width: 64px; height: 40px; min-width: 0; border: 0; border-radius: 0; padding: 0; background: transparent; text-align: center; font-variant-numeric: tabular-nums; }.generator-stepper .input:focus { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--primary) 36%, transparent); }.generator-number-field.compact { grid-template-columns: minmax(0, 1fr) auto; margin: -3px 0 2px 50px; color: var(--muted); font-size: 13px; }.generator-number-field.compact .generator-stepper { grid-template-columns: 32px 46px 32px; border-radius: 9px; }.generator-number-field.compact .generator-stepper button { width: 32px; height: 34px; }.generator-number-field.compact .generator-stepper .input { width: 46px; height: 34px; font-size: 13px; }
|
||||||
.generator-option-group { display: grid; gap: 9px; margin: 18px 0; padding: 0; border: 0; }.generator-option-group legend { margin-bottom: 10px; padding: 0; font-size: 14px; font-weight: 700; }
|
.generator-option-group { display: grid; gap: 9px; margin: 18px 0; padding: 0; border: 0; }.generator-option-group legend { margin-bottom: 10px; padding: 0; font-size: 14px; font-weight: 700; }
|
||||||
.generator-toggle { display: grid; grid-template-columns: 40px minmax(0, 1fr); align-items: center; gap: 10px; min-height: 32px; cursor: pointer; }.generator-toggle input { position: absolute; opacity: 0; }.generator-toggle > span { position: relative; width: 38px; height: 22px; border-radius: 999px; background: #cbd5e1; transition: background 180ms ease; }.generator-toggle > span::after { position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(15,23,42,.25); content: ''; transition: transform 180ms ease; }.generator-toggle input:checked + span { background: var(--primary); }.generator-toggle input:checked + span::after { transform: translateX(16px); }.generator-toggle input:focus-visible + span { outline: 3px solid color-mix(in srgb, var(--primary) 30%, transparent); outline-offset: 2px; }.generator-toggle strong { font-size: 14px; font-weight: 600; }
|
.generator-toggle { display: grid; grid-template-columns: 40px minmax(0, 1fr); align-items: center; gap: 10px; min-height: 32px; cursor: pointer; }.generator-toggle input { position: absolute; opacity: 0; }.generator-toggle > span { position: relative; width: 38px; height: 22px; border-radius: 999px; background: #cbd5e1; transition: background 180ms ease; }.generator-toggle > span::after { position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(15,23,42,.25); content: ''; transition: transform 180ms ease; }.generator-toggle input:checked + span { background: var(--primary); }.generator-toggle input:checked + span::after { transform: translateX(16px); }.generator-toggle input:focus-visible + span { outline: 3px solid color-mix(in srgb, var(--primary) 30%, transparent); outline-offset: 2px; }.generator-toggle strong { font-size: 14px; font-weight: 600; }
|
||||||
|
.generator-select-field, .generator-text-field { display: grid; gap: 8px; margin-bottom: 16px; color: var(--text); font-size: 14px; font-weight: 700; }.generator-select-field .input, .generator-text-field .input { width: 100%; }.generator-options-note { margin: 14px 0 0; color: var(--muted); font-size: 13px; line-height: 1.55; }
|
||||||
|
.generator-word-list-input { min-height: 118px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 13px; line-height: 1.5; }
|
||||||
|
.generator-key-status { display: flex; min-height: 180px; margin: 18px 0 10px; align-items: center; justify-content: center; gap: 10px; border: 1px solid var(--line); border-radius: 16px; color: var(--muted-strong); }.generator-key-status.error { padding: 18px; color: var(--danger); text-align: center; }
|
||||||
|
.generator-spinner { animation: generator-spin 900ms linear infinite; }.generator-key-output { display: grid; gap: 12px; margin: 18px 0 10px; }.generator-key-summary, .generator-key-field, .generator-private-key { border: 1px solid var(--line); border-radius: 14px; background: color-mix(in srgb, var(--primary) 4%, var(--panel)); }.generator-key-summary { display: grid; gap: 6px; padding: 14px; }.generator-key-summary code { overflow-wrap: anywhere; color: var(--muted-strong); font-size: 12px; }.generator-key-field { display: grid; gap: 9px; padding: 14px; }.generator-key-field > span { font-size: 13px; font-weight: 700; }.generator-key-field > code, .generator-private-key > code { display: block; max-height: 150px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 11px; line-height: 1.45; user-select: all; }.generator-key-field-actions { display: flex; flex-wrap: wrap; gap: 8px; }.generator-key-field-actions .btn { display: inline-flex; gap: 6px; }.generator-private-key { padding: 0 14px 14px; }.generator-private-key summary { padding: 14px 0; cursor: pointer; font-size: 13px; font-weight: 700; }.generator-private-key .generator-key-field-actions { margin-top: 10px; }
|
||||||
|
@keyframes generator-spin { to { transform: rotate(360deg); } }
|
||||||
.generator-inline-number { display: grid; grid-template-columns: minmax(0, 1fr) 72px; align-items: center; gap: 14px; margin: -3px 0 2px 50px; color: var(--muted); font-size: 13px; }.generator-inline-number .input { height: 34px; text-align: center; }
|
.generator-inline-number { display: grid; grid-template-columns: minmax(0, 1fr) 72px; align-items: center; gap: 14px; margin: -3px 0 2px 50px; color: var(--muted); font-size: 13px; }.generator-inline-number .input { height: 34px; text-align: center; }
|
||||||
@media (max-width: 760px) { .generator-page { width: 100%; padding: 0 0 18px; }.generator-layout { grid-template-columns: 1fr; grid-template-areas: 'output' 'options'; gap: 10px; }.generator-output-card, .generator-options-card { padding: 15px; border-radius: 16px; }.generator-value { min-height: 94px; margin: 14px 0 10px; padding: 14px; font-size: 19px; }.generator-actions .btn { justify-content: center; padding-inline: 10px; }.generator-option-group { margin: 15px 0; }.generator-toggle { min-height: 44px; }.generator-number-field.compact { margin-left: 50px; }.generator-stepper { grid-template-columns: 40px 64px 40px; }.generator-stepper button { width: 40px; min-height: 44px; }.generator-stepper .input { height: 44px; }.generator-number-field.compact .generator-stepper { grid-template-columns: 36px 46px 36px; }.generator-number-field.compact .generator-stepper button { width: 36px; height: 40px; min-height: 40px; } }
|
@media (max-width: 760px) { .generator-page { width: 100%; padding: 0 0 18px; }.generator-layout { grid-template-columns: 1fr; grid-template-areas: 'output' 'options'; gap: 10px; }.generator-output-card, .generator-options-card { padding: 15px; border-radius: 16px; }.generator-value { min-height: 94px; margin: 14px 0 10px; padding: 14px; font-size: 19px; }.generator-meta-row { grid-template-columns: minmax(60px, 1fr) auto; }.generator-meta-row > span:last-child { grid-column: 1 / -1; }.generator-actions .btn { justify-content: center; padding-inline: 10px; }.generator-option-group { margin: 15px 0; }.generator-toggle { min-height: 44px; }.generator-number-field.compact { margin-left: 50px; }.generator-stepper { grid-template-columns: 40px 64px 40px; }.generator-stepper button { width: 40px; min-height: 44px; }.generator-stepper .input { height: 44px; }.generator-number-field.compact .generator-stepper { grid-template-columns: 36px 46px 36px; }.generator-number-field.compact .generator-stepper button { width: 36px; height: 40px; min-height: 40px; } }
|
||||||
@media (prefers-reduced-motion: reduce) { .generator-toggle > span, .generator-toggle > span::after { transition: none; } }
|
@media (prefers-reduced-motion: reduce) { .generator-toggle > span, .generator-toggle > span::after { transition: none; }.generator-spinner { animation: none; } }
|
||||||
|
|||||||
+49
-59
@@ -102,6 +102,28 @@ async function appShellNavigation(request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function connectorNavigation(request) {
|
||||||
|
const runtimeCache = await caches.open(RUNTIME_CACHE);
|
||||||
|
try {
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (isCacheableResponse(response)) {
|
||||||
|
await runtimeCache.put(request, response.clone());
|
||||||
|
await trimRuntimeCache(runtimeCache, 120);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
const shellCache = await caches.open(APP_SHELL_CACHE);
|
||||||
|
const cached =
|
||||||
|
(await shellCache.match(request, { ignoreSearch: true }))
|
||||||
|
|| (await runtimeCache.match(request, { ignoreSearch: true }))
|
||||||
|
|| (await matchLegacyRuntimeCache(request));
|
||||||
|
return cached || new Response('WebAuthn connector is unavailable while offline.', {
|
||||||
|
status: 503,
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=UTF-8' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function trimRuntimeCache(cache, maxEntries) {
|
async function trimRuntimeCache(cache, maxEntries) {
|
||||||
const keys = await cache.keys();
|
const keys = await cache.keys();
|
||||||
if (keys.length <= maxEntries) return;
|
if (keys.length <= maxEntries) return;
|
||||||
@@ -145,6 +167,13 @@ self.addEventListener('fetch', (event) => {
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (NEVER_CACHE_PATH_RE.test(url.pathname)) return;
|
if (NEVER_CACHE_PATH_RE.test(url.pathname)) return;
|
||||||
|
|
||||||
|
// Connector navigations are protocol pages, not application routes. They must
|
||||||
|
// never be replaced with the SPA shell, even when the device is offline.
|
||||||
|
if (url.pathname.endsWith('-connector.html')) {
|
||||||
|
event.respondWith(connectorNavigation(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (request.mode === 'navigate') {
|
if (request.mode === 'navigate') {
|
||||||
event.respondWith(appShellNavigation(request));
|
event.respondWith(appShellNavigation(request));
|
||||||
if (navigator.onLine !== false) {
|
if (navigator.onLine !== false) {
|
||||||
@@ -229,39 +258,12 @@ function searchIndexPolicyPlugin(isDemo: boolean): Plugin {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function resourcePriorityPlugin(isDemo: boolean): Plugin {
|
|
||||||
return {
|
|
||||||
name: 'nodewarden-resource-priority',
|
|
||||||
enforce: 'post' as const,
|
|
||||||
transformIndexHtml(html: string) {
|
|
||||||
if (isDemo || !html.includes('/assets/app-suite-')) return html;
|
|
||||||
|
|
||||||
const scriptMatch = html.match(/^\s*<script type="module" crossorigin src="\/assets\/index-[^"]+\.js"><\/script>\s*$/m);
|
|
||||||
const appSuiteMatch = html.match(/^\s*<link rel="modulepreload" crossorigin href="\/assets\/app-suite-[^"]+\.js">\s*$/m);
|
|
||||||
const stylesheetMatch = html.match(/^\s*<link rel="stylesheet" crossorigin href="\/assets\/index-[^"]+\.css">\s*$/m);
|
|
||||||
|
|
||||||
if (!scriptMatch || !appSuiteMatch || !stylesheetMatch) return html;
|
|
||||||
|
|
||||||
const prioritizedTags = [
|
|
||||||
stylesheetMatch[0].replace('rel="stylesheet"', 'rel="stylesheet" fetchpriority="high"'),
|
|
||||||
appSuiteMatch[0].replace('rel="modulepreload"', 'rel="modulepreload" fetchpriority="high"'),
|
|
||||||
scriptMatch[0].replace('type="module"', 'type="module" fetchpriority="high"'),
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
return html
|
|
||||||
.replace(scriptMatch[0], '')
|
|
||||||
.replace(appSuiteMatch[0], '')
|
|
||||||
.replace(stylesheetMatch[0], prioritizedTags);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const isDemo = mode === 'demo';
|
const isDemo = mode === 'demo';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
root: rootDir,
|
root: rootDir,
|
||||||
plugins: [preact(), searchIndexPolicyPlugin(isDemo), resourcePriorityPlugin(isDemo), pwaServiceWorkerPlugin(isDemo)],
|
plugins: [preact(), searchIndexPolicyPlugin(isDemo), pwaServiceWorkerPlugin(isDemo)],
|
||||||
define: {
|
define: {
|
||||||
__NODEWARDEN_DEMO__: JSON.stringify(isDemo),
|
__NODEWARDEN_DEMO__: JSON.stringify(isDemo),
|
||||||
},
|
},
|
||||||
@@ -282,41 +284,29 @@ export default defineConfig(({ mode }) => {
|
|||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
target: 'esnext',
|
target: 'esnext',
|
||||||
chunkSizeWarningLimit: 800,
|
chunkSizeWarningLimit: 800,
|
||||||
rollupOptions: {
|
rolldownOptions: {
|
||||||
treeshake: {
|
checks: {
|
||||||
preset: 'smallest',
|
pluginTimings: false,
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
manualChunks(id) {
|
codeSplitting: {
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
name: 'shared',
|
||||||
|
minShareCount: 2,
|
||||||
|
minSize: 50 * 1024,
|
||||||
|
priority: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name(id) {
|
||||||
const normalized = id.replace(/\\/g, '/');
|
const normalized = id.replace(/\\/g, '/');
|
||||||
|
|
||||||
const localeMatch = normalized.match(/\/src\/lib\/i18n\/locales\/(.+)\.ts$/);
|
const localeMatch = normalized.match(/\/src\/lib\/i18n\/locales\/(.+)\.ts$/);
|
||||||
if (localeMatch) {
|
return localeMatch && localeMatch[1] !== 'en' ? `i18n-${localeMatch[1]}` : null;
|
||||||
if (localeMatch[1] === 'en') return undefined;
|
},
|
||||||
return `i18n-${localeMatch[1]}`;
|
test: /[\\/]src[\\/]lib[\\/]i18n[\\/]locales[\\/]/,
|
||||||
}
|
priority: 20,
|
||||||
|
},
|
||||||
if (
|
],
|
||||||
!isDemo &&
|
|
||||||
(
|
|
||||||
normalized.includes('/src/components/VaultPage.tsx') ||
|
|
||||||
normalized.includes('/src/components/ImportPage.tsx') ||
|
|
||||||
normalized.includes('/src/lib/import-') ||
|
|
||||||
normalized.includes('/src/lib/export-formats.ts') ||
|
|
||||||
normalized.includes('/src/components/SendsPage.tsx') ||
|
|
||||||
normalized.includes('/src/components/TotpCodesPage.tsx') ||
|
|
||||||
normalized.includes('/src/components/DomainRulesPage.tsx') ||
|
|
||||||
normalized.includes('/src/components/BackupCenterPage.tsx') ||
|
|
||||||
normalized.includes('/src/components/backup-center/') ||
|
|
||||||
normalized.includes('/src/components/SettingsPage.tsx') ||
|
|
||||||
normalized.includes('/src/components/SecurityDevicesPage.tsx') ||
|
|
||||||
normalized.includes('/src/components/AdminPage.tsx')
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return 'app-suite';
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ command = "npm run build"
|
|||||||
[assets]
|
[assets]
|
||||||
binding = "ASSETS"
|
binding = "ASSETS"
|
||||||
directory = "./dist"
|
directory = "./dist"
|
||||||
|
html_handling = "none"
|
||||||
not_found_handling = "single-page-application"
|
not_found_handling = "single-page-application"
|
||||||
run_worker_first = true
|
run_worker_first = true
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ command = "npm run build"
|
|||||||
[assets]
|
[assets]
|
||||||
binding = "ASSETS"
|
binding = "ASSETS"
|
||||||
directory = "./dist"
|
directory = "./dist"
|
||||||
|
html_handling = "none"
|
||||||
not_found_handling = "single-page-application"
|
not_found_handling = "single-page-application"
|
||||||
run_worker_first = true
|
run_worker_first = true
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user