Compare commits

...
Author SHA1 Message Date
dependabot[bot]andGitHub f46c0c4c33 chore(deps): bump the npm-minor-and-patch group across 1 directory with 13 updates
Bumps the npm-minor-and-patch group with 13 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@noble/hashes](https://github.com/paulmillr/noble-hashes) | `2.2.0` | `2.3.0` |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.101.2` | `5.101.4` |
| [@zip.js/zip.js](https://github.com/gildas-lormeau/zip.js) | `2.8.26` | `2.8.34` |
| [lucide-preact](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-preact) | `1.22.0` | `1.30.0` |
| [preact](https://github.com/preactjs/preact) | `10.29.3` | `10.29.8` |
| [@preact/preset-vite](https://github.com/preactjs/preset-vite) | `2.10.5` | `2.10.6` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.0.1` | `26.1.2` |
| [autoprefixer](https://github.com/postcss/autoprefixer) | `10.5.2` | `10.5.4` |
| [opencc-js](https://github.com/nk2028/opencc-js) | `1.3.2` | `1.4.1` |
| [postcss](https://github.com/postcss/postcss) | `8.5.23` | `8.5.26` |
| [tsx](https://github.com/privatenumber/tsx) | `4.22.4` | `4.23.10` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.1.3` | `8.2.1` |
| [wrangler](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler) | `4.105.0` | `4.120.0` |



Updates `@noble/hashes` from 2.2.0 to 2.3.0
- [Release notes](https://github.com/paulmillr/noble-hashes/releases)
- [Commits](https://github.com/paulmillr/noble-hashes/compare/2.2.0...2.3.0)

Updates `@tanstack/react-query` from 5.101.2 to 5.101.4
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.101.4/packages/react-query)

Updates `@zip.js/zip.js` from 2.8.26 to 2.8.34
- [Release notes](https://github.com/gildas-lormeau/zip.js/releases)
- [Commits](https://github.com/gildas-lormeau/zip.js/compare/v2.8.26...v2.8.34)

Updates `lucide-preact` from 1.22.0 to 1.30.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.30.0/packages/lucide-preact)

Updates `preact` from 10.29.3 to 10.29.8
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.29.3...10.29.8)

Updates `@preact/preset-vite` from 2.10.5 to 2.10.6
- [Release notes](https://github.com/preactjs/preset-vite/releases)
- [Changelog](https://github.com/preactjs/preset-vite/blob/main/CHANGELOG.md)
- [Commits](https://github.com/preactjs/preset-vite/commits)

Updates `@types/node` from 26.0.1 to 26.1.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `autoprefixer` from 10.5.2 to 10.5.4
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.5.2...10.5.4)

Updates `opencc-js` from 1.3.2 to 1.4.1
- [Release notes](https://github.com/nk2028/opencc-js/releases)
- [Changelog](https://github.com/nk2028/opencc-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/nk2028/opencc-js/compare/v1.3.2...v1.4.1)

Updates `postcss` from 8.5.23 to 8.5.26
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.23...8.5.26)

Updates `tsx` from 4.22.4 to 4.23.10
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.22.4...v4.23.10)

Updates `vite` from 8.1.3 to 8.2.1
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.2.1/packages/vite)

Updates `wrangler` from 4.105.0 to 4.120.0
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/wrangler@4.120.0/packages/wrangler)

---
updated-dependencies:
- dependency-name: "@noble/hashes"
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@preact/preset-vite"
  dependency-version: 2.10.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.101.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 26.1.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@zip.js/zip.js"
  dependency-version: 2.8.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: autoprefixer
  dependency-version: 10.5.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: lucide-preact
  dependency-version: 1.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: opencc-js
  dependency-version: 1.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: postcss
  dependency-version: 8.5.26
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: preact
  dependency-version: 10.29.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: tsx
  dependency-version: 4.23.9
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: vite
  dependency-version: 8.2.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: wrangler
  dependency-version: 4.119.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-10 12:05:37 +00:00
shuaiplus b3630bdaf5 fix(deps): update nanoid to 3.3.17 and undici to 8.9.0 2026-08-10 20:01:43 +08:00
Cordero CoreandGitHub 38b0ff6263 fix(vault): let the list toolbar wrap instead of overflowing (#348)
.list-head is a single-line flex row whose buttons are nowrap and
cannot shrink below their labels. The duplicates view adds a detection
mode select and a Select-duplicates button to the standard
search/sort/sync set, exceeding the list column (capped at 540px on
desktop), so the shrinkable controls crush to slivers and the fixed
buttons overlap and spill out of the column. Allow wrapping: views
that fit stay on one line; crowded toolbars flow to a second row.
Mobile is unaffected (it switches .list-head to its own grid).
2026-08-10 19:39:36 +08:00
Cordero CoreandGitHub ecc0d134ac fix(devices): wrap authorized-device action buttons instead of clipping (#347)
.authorized-devices-actions forced its four buttons (Untrust, Trust
permanently, Device note, Delete) onto one non-wrapping, non-shrinking
line inside the fixed 26% actions column. At common desktop widths the
row overflows the column and table-layout: fixed clips it at the panel
edge, cutting off Device note and hiding Delete entirely. Let the
buttons wrap to a second line instead.
2026-08-10 19:39:15 +08:00
Cordero CoreandGitHub f644baaf8d fix(vault): stop detail-row value column collapsing to zero width (#346)
The .kv-row grid sized its actions column with auto, letting it claim
content width before the minmax(0, 1fr) value column. Once the
Check breach button joined Reveal and Copy, label + actions could
exceed the row width, resolving the value column to 0px; combined
with overflow-wrap: anywhere this rendered masked passwords as a
vertical column of one asterisk per line at common desktop widths.

Give the value column a floor of min(35%, 140px) so the actions
column shrinks and wraps its buttons (kv-actions already has
flex-wrap) before the value collapses.
2026-08-10 19:38:52 +08:00
Cordero CoreandGitHub fb627f59f0 fix(styles): use theme tokens for hardcoded brand blues (#345)
Replace hardcoded #1d4ed8 / #2563eb / #bfdbfe values with their exact
design-token equivalents (--primary, --primary-hover, --primary-strong)
in 17 declarations across auth, dark, management, and vault styles.

Light theme is pixel-identical: every replaced hex equals the token's
light value. In dark theme this fixes spots that dark.css never
overrode and that kept light-theme blues on dark backgrounds:
standalone footer links and version badge, JWT warning inline link,
restore-progress active dot, TOTP countdown ring, and the
authorized-device checkbox accent.

Intentionally left alone: .btn-primary gradients (would lighten dark
buttons under white text), card brand colors (Amex/Maestro/RuPay blues
are brand constants, not theme colors), and light-pill pairings whose
backgrounds have no token (.log-mode-option.active, .log-category-auth,
.log-level-info, .folder-edit-btn:hover, #93c5fd borders).
2026-08-10 19:38:27 +08:00
shuaiplus 652952379b fix: stop accepting backup blob credentials from the URL 2026-07-26 16:00:39 +08:00
shuaiplus 34fd2f0259 build: optimize Rolldown chunk splitting 2026-07-25 22:49:02 +08:00
EnvyBackyardandGitHub df493b4697 feat: expand password generator options (#329) 2026-07-25 22:23:02 +08:00
shuaiplus f6403b8cab fix: upgrade PostCSS to patch path traversal vulnerability 2026-07-25 21:44:22 +08:00
shuaiplus a0128908e1 fix: align API responses with Bitwarden 2026.7 clients 2026-07-25 21:25:51 +08:00
shuaiplus 8e5d9e294b fix: update sharp to 0.35.0 2026-07-23 18:28:00 +08:00
shuaiplus 6ffdf05dc6 fix: enable official desktop browser integration settings
Return Bitwarden's desktop-ui-settings-dialog feature state so supported desktop clients use the official settings dialog with browser integration controls.

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

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

Fixes #326
2026-07-23 00:39:34 +08:00
44 changed files with 2689 additions and 816 deletions
+451 -451
View File
File diff suppressed because it is too large Load Diff
+22 -15
View File
@@ -15,6 +15,11 @@
"domains:sync": "node scripts/sync-global-domains.mjs", "domains:sync": "node scripts/sync-global-domains.mjs",
"i18n": "node scripts/i18n-validate.cjs", "i18n": "node scripts/i18n-validate.cjs",
"i18n:validate": "node scripts/i18n-validate.cjs", "i18n:validate": "node scripts/i18n-validate.cjs",
"test:config-compatibility": "tsx --test scripts/config-compatibility.test.ts",
"test:web-crypto": "tsx --test scripts/web-crypto-availability.test.ts",
"test:webauthn-mobile": "node --test scripts/webauthn-mobile-connector.test.mjs",
"test:webauthn-connector": "node --test scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts",
"test:webauthn-connectors": "node --test scripts/webauthn-mobile-connector.test.mjs scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts",
"deploy": "wrangler deploy", "deploy": "wrangler deploy",
"deploy:kv": "node scripts/ensure-kv.cjs && wrangler deploy -c wrangler.kv.toml", "deploy:kv": "node scripts/ensure-kv.cjs && wrangler deploy -c wrangler.kv.toml",
"deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo" "deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo"
@@ -43,33 +48,35 @@
} }
}, },
"overrides": { "overrides": {
"undici": ">=7.28.0", "nanoid": "3.3.17",
"undici": "8.9.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",
"@preact/preset-vite": "^2.10.5", "@preact/preset-vite": "^2.10.6",
"@types/node": "^26.0.1", "@types/node": "^26.1.2",
"autoprefixer": "^10.5.2", "autoprefixer": "^10.5.4",
"opencc-js": "^1.3.2", "opencc-js": "^1.4.1",
"postcss": "^8.5.16", "postcss": "^8.5.26",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"tsx": "^4.22.4", "tsx": "^4.23.10",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.1.3", "vite": "^8.2.1",
"wrangler": "^4.105.0" "wrangler": "^4.120.0"
}, },
"dependencies": { "dependencies": {
"@noble/hashes": "^2.2.0", "@noble/hashes": "^2.3.0",
"@simplewebauthn/server": "^13.3.2", "@simplewebauthn/server": "^13.3.2",
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.4",
"@zip.js/zip.js": "^2.8.26", "@zip.js/zip.js": "^2.8.34",
"fflate": "^0.8.3", "fflate": "^0.8.3",
"jsqr": "1.4.0", "jsqr": "1.4.0",
"lucide-preact": "^1.22.0", "lucide-preact": "^1.30.0",
"preact": "^10.29.3", "preact": "^10.29.8",
"qrcode-generator": "^2.0.4", "qrcode-generator": "^2.0.4",
"wouter": "^3.10.0" "wouter": "^3.10.0"
} }
+12
View File
@@ -0,0 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildConfigResponse } from '../src/config-response';
test('config enables the official Bitwarden desktop settings dialog', () => {
const body = buildConfigResponse('https://vault.example.test');
assert.equal(body.featureStates['desktop-ui-settings-dialog'], true);
assert.equal(body.environment.vault, 'https://vault.example.test');
assert.equal(body.object, 'config');
});
+84
View File
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerAccount } from '../webapp/src/lib/api/auth';
import {
requireWebCrypto,
WebCryptoUnavailableError,
} from '../webapp/src/lib/crypto';
const supportedCrypto = {
subtle: {
importKey: () => Promise.reject(new Error('not used by capability checks')),
},
getRandomValues: <T>(array: T): T => array,
} as unknown as Crypto;
function restoreGlobalProperty(name: string, descriptor: PropertyDescriptor | undefined): void {
if (descriptor) {
Object.defineProperty(globalThis, name, descriptor);
return;
}
delete (globalThis as unknown as Record<string, unknown>)[name];
}
test('Web Crypto guard rejects insecure browser contexts', () => {
assert.throws(
() => requireWebCrypto({ crypto: supportedCrypto, isSecureContext: false }),
WebCryptoUnavailableError
);
});
test('Web Crypto guard rejects secure contexts without SubtleCrypto', () => {
const cryptoWithoutSubtle = {
getRandomValues: <T>(array: T): T => array,
} as unknown as Crypto;
assert.throws(
() => requireWebCrypto({ crypto: cryptoWithoutSubtle, isSecureContext: true }),
WebCryptoUnavailableError
);
});
test('Web Crypto guard accepts a secure supported browser', () => {
assert.equal(
requireWebCrypto({ crypto: supportedCrypto, isSecureContext: true }),
supportedCrypto
);
});
test('registration returns an actionable error without contacting the backend', async () => {
const cryptoDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto');
const secureContextDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'isSecureContext');
const fetchDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'fetch');
let fetchCalled = false;
Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true });
Object.defineProperty(globalThis, 'isSecureContext', { value: false, configurable: true });
Object.defineProperty(globalThis, 'fetch', {
configurable: true,
value: async () => {
fetchCalled = true;
return new Response(null, { status: 500 });
},
});
try {
const result = await registerAccount({
email: 'first@example.test',
name: 'First Admin',
password: 'correct horse battery staple',
fallbackIterations: 600_000,
});
assert.deepEqual(result, {
ok: false,
message: 'Secure browser cryptography is unavailable. Open NodeWarden over HTTPS in a supported browser.',
});
assert.equal(fetchCalled, false);
} finally {
restoreGlobalProperty('crypto', cryptoDescriptor);
restoreGlobalProperty('isSecureContext', secureContextDescriptor);
restoreGlobalProperty('fetch', fetchDescriptor);
}
});
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import type { Env } from '../src/types';
import { getConfiguredWebAuthnAllowedOrigins } from '../src/utils/origins';
import { applyCors, handleCors } from '../src/utils/response';
const env = {} as Env;
test('only the iframe connector drops anti-framing headers', () => {
const connectorRequest = new Request('https://vault.example.test/webauthn-connector.html');
const connector = applyCors(connectorRequest, new Response('<!doctype html>'), env);
assert.equal(connector.headers.get('X-Frame-Options'), null);
assert.doesNotMatch(connector.headers.get('Content-Security-Policy') || '', /frame-ancestors/);
assert.match(connector.headers.get('Content-Security-Policy') || '', /script-src 'self'/);
for (const path of ['/', '/webauthn-fallback-connector.html', '/webauthn-mobile-connector.html']) {
const request = new Request(`https://vault.example.test${path}`);
const response = applyCors(request, new Response('<!doctype html>'), env);
assert.equal(response.headers.get('X-Frame-Options'), 'DENY');
assert.match(response.headers.get('Content-Security-Policy') || '', /frame-ancestors 'none'/);
}
});
test('official Bitwarden desktop origin receives credentialed CORS', () => {
assert.ok(getConfiguredWebAuthnAllowedOrigins(env).includes('bw-desktop-file://bundle'));
const preflight = handleCors(new Request('https://vault.example.test/api/sync', {
method: 'OPTIONS',
headers: {
Origin: 'bw-desktop-file://bundle',
'Access-Control-Request-Headers': 'authorization, content-type',
},
}), env);
assert.equal(preflight.headers.get('Access-Control-Allow-Origin'), 'bw-desktop-file://bundle');
assert.equal(preflight.headers.get('Access-Control-Allow-Credentials'), 'true');
});
test('Worker assets preserve exact official connector .html paths', async () => {
for (const configUrl of [
new URL('../wrangler.toml', import.meta.url),
new URL('../wrangler.kv.toml', import.meta.url),
]) {
const config = await readFile(configUrl, 'utf8');
const assetsSection = config.match(/\[assets\]([\s\S]*?)(?=\n\[|$)/)?.[1] || '';
assert.match(assetsSection, /^\s*html_handling\s*=\s*"none"\s*$/m);
}
});
+126
View File
@@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import {
buildCredentialData,
normalizePublicKeyOptions,
parseConnectorRequest,
resolveParentChannel,
} from '../webapp/public/webauthn-connector.js';
function encodeBase64Utf8(value) {
return Buffer.from(value, 'utf8').toString('base64');
}
const publicKeyOptions = {
challenge: 'AQID',
allowCredentials: [{ id: 'BAUG', type: 'public-key', transports: ['usb'] }],
timeout: 60000,
rpId: 'vault.example.test',
};
test('parses the official desktop/browser V1 connector request', () => {
const params = new URLSearchParams({
data: encodeBase64Utf8(JSON.stringify(publicKeyOptions)),
parent: encodeURIComponent('file:///C:/Program Files/Bitwarden/resources/app/index.html'),
btnText: encodeURIComponent('Read security key'),
btnAwaitingInteractionText: encodeURIComponent('Awaiting security key interaction...'),
v: '1',
});
const request = parseConnectorRequest(params);
assert.equal(request.parentUrl, 'file:///C:/Program Files/Bitwarden/resources/app/index.html');
assert.equal(request.parentProtocol, 'file:');
assert.deepEqual(JSON.parse(request.webauthnJson), publicKeyOptions);
assert.equal(request.buttonText, 'Read security key');
assert.equal(request.awaitingText, 'Awaiting security key interaction...');
});
test('keeps V2 parsing compatible with the shared official connector protocol', () => {
const params = new URLSearchParams({
data: encodeBase64Utf8(JSON.stringify({ data: JSON.stringify(publicKeyOptions) })),
parent: encodeURIComponent('chrome-extension://nngceckbapebfimnlniiiahkandclblb/popup/index.html'),
v: '2',
});
assert.deepEqual(JSON.parse(parseConnectorRequest(params).webauthnJson), publicKeyOptions);
});
test('normalizes WebAuthn challenge and allowed credential IDs', () => {
const normalized = normalizePublicKeyOptions(JSON.stringify(publicKeyOptions));
assert.deepEqual(Array.from(normalized.challenge), [1, 2, 3]);
assert.deepEqual(Array.from(normalized.allowCredentials[0].id), [4, 5, 6]);
});
test('emits the exact assertion shape consumed by official Bitwarden clients', () => {
const output = JSON.parse(buildCredentialData({
id: 'credential-id',
rawId: Uint8Array.from([1, 2, 3]).buffer,
type: 'public-key',
getClientExtensionResults: () => ({ appid: false }),
response: {
authenticatorData: Uint8Array.from([4, 5]).buffer,
clientDataJSON: Uint8Array.from([6, 7]).buffer,
signature: Uint8Array.from([8, 9]).buffer,
},
}));
assert.deepEqual(output, {
id: 'credential-id',
rawId: 'AQID',
type: 'public-key',
extensions: { appid: false },
response: {
authenticatorData: 'BAU',
clientDataJson: 'Bgc',
signature: 'CAk',
},
});
});
test('accepts legacy file and current official desktop parent origins', () => {
assert.deepEqual(resolveParentChannel({
parentProtocol: 'file:',
parentUrl: 'file:///C:/Bitwarden/index.html',
}, 'https://vault.example.test'), {
eventOrigin: 'null',
targetOrigin: 'file:///C:/Bitwarden/index.html',
});
assert.deepEqual(resolveParentChannel({
parentProtocol: 'bw-desktop-file:',
parentUrl: 'bw-desktop-file://bundle/index.html',
}, 'https://vault.example.test'), {
eventOrigin: 'bw-desktop-file://bundle',
targetOrigin: 'bw-desktop-file://bundle/index.html',
});
});
test('accepts configured official extension origins and rejects arbitrary parents', () => {
const extension = 'chrome-extension://nngceckbapebfimnlniiiahkandclblb';
assert.deepEqual(resolveParentChannel({
parentProtocol: 'chrome-extension:',
parentUrl: `${extension}/popup/index.html`,
}, 'https://vault.example.test', [extension]), {
eventOrigin: extension,
targetOrigin: extension,
});
assert.throws(() => resolveParentChannel({
parentProtocol: 'https:',
parentUrl: 'https://attacker.example/frame',
}, 'https://vault.example.test', []), /Untrusted parent/);
});
test('uses the official postMessage message contract and iframe-sized fallback styling', async () => {
const [html, source, viteConfig] = await Promise.all([
readFile(new URL('../webapp/public/webauthn-connector.html', import.meta.url), 'utf8'),
readFile(new URL('../webapp/public/webauthn-connector.js', import.meta.url), 'utf8'),
readFile(new URL('../webapp/vite.config.ts', import.meta.url), 'utf8'),
]);
assert.match(html, /id="webauthn-button"/);
assert.match(html, /min-height:\s*40px/);
assert.match(html, /background:\s*#2563eb/);
assert.match(source, /post\('info\|ready'\)/);
assert.match(source, /post\(`success\|\$\{buildCredentialData\(credential\)\}`\)/);
assert.match(source, /post\(`error\|\$\{browserErrorMessage\(error\)\}`\)/);
assert.match(source, /event\.data === 'stop'/);
assert.match(source, /event\.data === 'start'/);
assert.match(viteConfig, /endsWith\('-connector\.html'\)/);
});
+135
View File
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import {
base64UrlFromBuffer,
buildCallbackUrl,
buildCredentialData,
decodeBase64Utf8,
normalizePublicKeyOptions,
parseConnectorRequest,
resolveMobileCallbackUri,
} from '../webapp/public/webauthn-mobile-connector.js';
function encodeBase64Utf8(value) {
return Buffer.from(value, 'utf8').toString('base64');
}
function v2Search(payload, extra = '') {
return `?data=${encodeURIComponent(encodeBase64Utf8(JSON.stringify(payload)))}&parent=bitwarden%3A__webauthn-callback&v=2${extra}`;
}
const assertionOptions = {
challenge: 'AQID-v8',
rpId: 'vault.example.com',
timeout: 60000,
userVerification: 'preferred',
allowCredentials: [{ id: 'BAUGBwg', type: 'public-key', transports: ['internal'] }],
};
test('parses the current Bitwarden Android V2 connector payload', () => {
const request = parseConnectorRequest(v2Search({
btnReturnText: 'Return to app', btnText: 'Authenticate', data: JSON.stringify(assertionOptions),
headerText: 'Verify your identity', mobile: true,
}, '&client=mobile&deeplinkScheme=bitwarden'), 'vault.example.com');
assert.equal(request.callbackUri, 'bitwarden://webauthn-callback');
assert.equal(request.headerText, 'Verify your identity');
assert.equal(request.buttonText, 'Authenticate');
assert.equal(request.returnButtonText, 'Return to app');
assert.deepEqual(JSON.parse(request.webauthnJson), assertionOptions);
});
test('uses callbackUri only as a signal and never as the redirect target', () => {
const trustedLooking = parseConnectorRequest(v2Search({
callbackUri: 'https://bitwarden.eu/webauthn-callback', data: assertionOptions,
}).replace('&parent=bitwarden%3A__webauthn-callback', ''));
const attacker = parseConnectorRequest(v2Search({
callbackUri: 'https://attacker.example/capture', data: assertionOptions,
}).replace('&parent=bitwarden%3A__webauthn-callback', ''));
assert.equal(trustedLooking.callbackUri, 'bitwarden://webauthn-callback');
assert.equal(attacker.callbackUri, 'bitwarden://webauthn-callback');
});
test('treats any non-HTTPS deeplinkScheme as the fixed Bitwarden custom scheme', () => {
const request = parseConnectorRequest(v2Search({ mobile: true, data: assertionOptions }, '&deeplinkScheme=untrusted'));
assert.equal(request.callbackUri, 'bitwarden://webauthn-callback');
});
test('supports Android custom-scheme and official HTTPS App Link callbacks', () => {
const payload = { mobile: true, data: assertionOptions };
const custom = parseConnectorRequest(v2Search(payload, '&client=mobile&deeplinkScheme=bitwarden'));
const eu = parseConnectorRequest(v2Search(payload, '&client=mobile&deeplinkScheme=https'), 'vault.bitwarden.eu');
const selfHosted = parseConnectorRequest(v2Search(payload, '&client=mobile&deeplinkScheme=https'), 'vault.example.com');
assert.equal(custom.callbackUri, 'bitwarden://webauthn-callback');
assert.equal(eu.callbackUri, 'https://bitwarden.eu/webauthn-callback');
assert.equal(selfHosted.callbackUri, 'https://bitwarden.com/webauthn-callback');
});
test('supports V1 mobile requests and requires a recognized mobile signal', () => {
const encoded = encodeURIComponent(encodeBase64Utf8(JSON.stringify(assertionOptions)));
assert.equal(parseConnectorRequest(`?data=${encoded}&v=1&client=mobile`).callbackUri, 'bitwarden://webauthn-callback');
assert.equal(resolveMobileCallbackUri({ payload: {}, hostname: 'vault.example.com' }), null);
assert.throws(() => parseConnectorRequest(`?data=${encoded}&v=1`), /return target/i);
});
test('decodes UTF-8 and normalizes WebAuthn binary fields without mutation', () => {
assert.equal(decodeBase64Utf8(encodeBase64Utf8('验证身份')), '验证身份');
const original = structuredClone(assertionOptions);
const normalized = normalizePublicKeyOptions(original);
assert.deepEqual(Array.from(normalized.challenge), [1, 2, 3, 250, 255]);
assert.deepEqual(Array.from(normalized.allowCredentials[0].id), [4, 5, 6, 7, 8]);
assert.deepEqual(original, assertionOptions);
});
test('serializes the exact assertion shape emitted by Bitwarden common-webauthn', () => {
const serialized = JSON.parse(buildCredentialData({
id: 'credential-id', rawId: Uint8Array.from([1, 2, 255]).buffer, type: 'public-key',
getClientExtensionResults: () => ({ appid: false }),
response: {
authenticatorData: Uint8Array.from([3, 4]).buffer,
clientDataJSON: Uint8Array.from([5, 6]).buffer,
signature: Uint8Array.from([7, 8]).buffer,
userHandle: Uint8Array.from([9, 10]).buffer,
},
}));
assert.deepEqual(serialized, {
id: 'credential-id',
rawId: 'AQL_',
type: 'public-key',
extensions: { appid: false },
response: { authenticatorData: 'AwQ', clientDataJson: 'BQY', signature: 'Bwg' },
});
assert.equal(base64UrlFromBuffer(Uint8Array.from([251, 255])), '-_8');
});
test('encodes success and error callbacks safely', () => {
assert.equal(buildCallbackUrl('bitwarden://webauthn-callback', 'data', '{"id":"a+b"}'), 'bitwarden://webauthn-callback?data=%7B%22id%22%3A%22a%2Bb%22%7D');
assert.equal(buildCallbackUrl('bitwarden://webauthn-callback?source=nodewarden', 'error', 'Not allowed'), 'bitwarden://webauthn-callback?source=nodewarden&error=Not%20allowed');
});
test('HTML matches the fallback connector visual structure', async () => {
const html = await readFile(new URL('../webapp/public/webauthn-mobile-connector.html', import.meta.url), 'utf8');
assert.match(html, /id="webauthn-header"/);
assert.match(html, /id="webauthn-button"/);
assert.match(html, /class="connector-card"/);
assert.match(html, /class="brand"/);
assert.match(html, /class="form"/);
assert.match(html, /class="msg"/);
assert.match(html, /src="\/nodewarden-logo\.svg"/);
assert.match(html, /src="\/webauthn-mobile-connector\.js"/);
assert.match(html, /default-src 'none'/);
});
test('runtime uses Bitwarden-compatible replacement navigation', async () => {
const source = await readFile(new URL('../webapp/public/webauthn-mobile-connector.js', import.meta.url), 'utf8');
assert.match(source, /window\.location\.replace\(uri\)/);
assert.doesNotMatch(source, /location\.assign/);
assert.doesNotMatch(source, /safeCallbackFromPayload/);
});
test('Service Worker keeps connector navigations out of the SPA shell', async () => {
const config = await readFile(new URL('../webapp/vite.config.ts', import.meta.url), 'utf8');
assert.match(config, /url\.pathname\.endsWith\('-connector\.html'\)/);
assert.match(config, /connectorNavigation\(request\)/);
assert.match(config, /WebAuthn connector is unavailable while offline/);
});
+53
View File
@@ -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',
};
}
+3 -1
View File
@@ -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。
+4 -9
View File
@@ -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,
+10
View File
@@ -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,
+16 -1
View File
@@ -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
View File
@@ -36,6 +36,7 @@ import { jsonResponse, unsupportedResponse } from './utils/response';
import { StorageService } from './services/storage'; import { StorageService } from './services/storage';
import type { Env } from './types'; import type { Env } from './types';
import { getConfiguredWebAuthnAllowedOrigins } from './utils/origins'; import { getConfiguredWebAuthnAllowedOrigins } from './utils/origins';
import { buildConfigResponse } from './config-response';
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>; type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
type JwtUnsafeReason = 'missing' | 'too_short' | null; type JwtUnsafeReason = 'missing' | 'too_short' | null;
@@ -95,60 +96,6 @@ function handleMissingWebsiteIcon(): Response {
}); });
} }
function buildIconServiceBase(origin: string): string {
return `${origin}/icons`;
}
function buildIconServiceTemplate(origin: string): string {
return `${buildIconServiceBase(origin)}/{}/icon.png`;
}
function buildIconServiceCsp(origin: string): string {
return `img-src 'self' data: ${origin}`;
}
function buildConfigResponse(origin: string) {
const fillAssistBase = `${origin}/fill-assist/`;
return {
version: LIMITS.compatibility.bitwardenServerVersion,
gitHash: 'nodewarden',
server: null,
environment: {
cloudRegion: 'self-hosted',
vault: origin,
api: origin + '/api',
identity: origin + '/identity',
notifications: origin + '/notifications',
icons: origin,
sso: '',
fillAssistRules: fillAssistBase,
},
push: {
pushTechnology: 0,
vapidPublicKey: null,
},
communication: null,
settings: {
disableUserRegistration: false,
},
_icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin),
featureStates: {
'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled,
'duo-redirect': true,
'email-verification': true,
'fill-assist-targeting-rules': true,
'pm-19051-send-email-verification': false,
'pm-19148-innovation-archive': true,
'pm-4516-devices-add-last-activity-date': true,
'pm-30529-webauthn-related-origins': true,
'unauth-ui-refresh': true,
'web-push': false,
},
object: 'config',
};
}
function normalizeIconHost(rawHost: string): string | null { function normalizeIconHost(rawHost: string): string | null {
let decoded: string; let decoded: string;
try { try {
+18 -1
View File
@@ -8,6 +8,13 @@ export const OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS = [
'chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn', 'chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn',
] as const; ] as const;
// Bitwarden desktop is migrating from file:// to this privileged Electron
// origin. Official clients keep the legacy file:// path as a compatibility
// fallback while self-hosted servers add CORS support for the new origin.
export const OFFICIAL_BITWARDEN_DESKTOP_ORIGINS = [
'bw-desktop-file://bundle',
] as const;
export function normalizeOrigin(value: unknown): string | null { export function normalizeOrigin(value: unknown): string | null {
const raw = String(value || '').trim(); const raw = String(value || '').trim();
if (!raw) return null; if (!raw) return null;
@@ -30,10 +37,20 @@ export function isBrowserExtensionOrigin(origin: unknown): boolean {
); );
} }
export function isOfficialBitwardenDesktopOrigin(origin: unknown): boolean {
const normalized = normalizeOrigin(origin);
return !!normalized && OFFICIAL_BITWARDEN_DESKTOP_ORIGINS.includes(
normalized as (typeof OFFICIAL_BITWARDEN_DESKTOP_ORIGINS)[number]
);
}
export function getConfiguredWebAuthnAllowedOrigins( export function getConfiguredWebAuthnAllowedOrigins(
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'> env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>
): string[] { ): string[] {
const seen = new Set<string>(OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS); const seen = new Set<string>([
...OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS,
...OFFICIAL_BITWARDEN_DESKTOP_ORIGINS,
]);
for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) { for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) {
const origin = normalizeOrigin(item); const origin = normalizeOrigin(item);
if (origin) seen.add(origin); if (origin) seen.add(origin);
+19 -3
View File
@@ -3,6 +3,7 @@ import type { Env } from '../types';
import { import {
isBrowserExtensionOrigin, isBrowserExtensionOrigin,
isConfiguredWebAuthnAllowedOrigin, isConfiguredWebAuthnAllowedOrigin,
isOfficialBitwardenDesktopOrigin,
normalizeOrigin, normalizeOrigin,
} from './origins'; } from './origins';
@@ -48,7 +49,10 @@ function getCorsPolicy(request: Request, env: Env): { allowOrigin: string | null
if (origin === url.origin) { if (origin === url.origin) {
return { allowOrigin: origin, allowCredentials: true }; return { allowOrigin: origin, allowCredentials: true };
} }
if (isBrowserExtensionOrigin(origin) && isConfiguredWebAuthnAllowedOrigin(env, origin)) { if (
(isBrowserExtensionOrigin(origin) || isOfficialBitwardenDesktopOrigin(origin))
&& isConfiguredWebAuthnAllowedOrigin(env, origin)
) {
return { allowOrigin: origin, allowCredentials: true }; return { allowOrigin: origin, allowCredentials: true };
} }
if (isWildcardCorsPath(url.pathname)) { if (isWildcardCorsPath(url.pathname)) {
@@ -100,10 +104,22 @@ export function applyCors(
headers.set(k, v); headers.set(k, v);
} }
// Security headers applied to every response. // Security headers applied to every response.
headers.set('X-Frame-Options', 'DENY');
headers.set('X-Content-Type-Options', 'nosniff'); headers.set('X-Content-Type-Options', 'nosniff');
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
if (!headers.has('Content-Security-Policy')) { const isWebAuthnFrameConnector = new URL(request.url).pathname === '/webauthn-connector.html';
if (isWebAuthnFrameConnector) {
// Official desktop and browser clients render this exact endpoint inside a
// 40px cross-origin iframe. The connector validates its parent before any
// WebAuthn request or postMessage, so only this protocol page may be framed.
headers.delete('X-Frame-Options');
headers.set(
'Content-Security-Policy',
"default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'"
);
} else {
headers.set('X-Frame-Options', 'DENY');
}
if (!isWebAuthnFrameConnector && !headers.has('Content-Security-Policy')) {
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:"); headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:");
} }
return new Response(response.body, { return new Response(response.body, {
+4
View File
@@ -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',
}; };
} }
+74
View File
@@ -0,0 +1,74 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'"
/>
<title>NodeWarden WebAuthn Connector</title>
<style>
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: transparent;
}
body {
display: flex;
align-items: center;
justify-content: center;
}
button {
width: 100%;
min-height: 40px;
padding: 8px 14px;
border: 1px solid #2563eb;
border-radius: 10px;
background: #2563eb;
color: #fff;
cursor: pointer;
font: inherit;
font-weight: 800;
line-height: 1.2;
transition: background-color 160ms ease, border-color 160ms ease;
}
button:hover {
border-color: #1d4ed8;
background: #1d4ed8;
}
button:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
button[aria-disabled="true"] {
border-color: #d0d5dd;
background: #d0d5dd;
color: #667085;
cursor: not-allowed;
}
</style>
</head>
<body>
<button id="webauthn-button" type="button" aria-live="polite">Read security key</button>
<script type="module" src="/webauthn-connector.js"></script>
</body>
</html>
+268
View File
@@ -0,0 +1,268 @@
const OFFICIAL_DESKTOP_ORIGIN = 'bw-desktop-file://bundle';
function safeDecodeURIComponent(value) {
let decoded = String(value || '');
for (let index = 0; index < 2 && /%[0-9a-f]{2}/i.test(decoded); index += 1) {
try {
const next = decodeURIComponent(decoded);
if (next === decoded) break;
decoded = next;
} catch (_error) {
break;
}
}
return decoded;
}
export function decodeBase64Utf8(value) {
let normalized = String(value || '').replace(/ /g, '+').replace(/-/g, '+').replace(/_/g, '/');
normalized += '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
let binary;
try {
binary = atob(normalized);
} catch (_error) {
throw new Error('Cannot parse WebAuthn data.');
}
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
if (typeof TextDecoder !== 'undefined') return new TextDecoder().decode(bytes);
return decodeURIComponent(Array.from(bytes, (byte) => `%${byte.toString(16).padStart(2, '0')}`).join(''));
}
export function bytesFromBase64Url(value) {
let normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
normalized += '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
try {
return Uint8Array.from(atob(normalized), (character) => character.charCodeAt(0));
} catch (_error) {
throw new Error('Cannot parse WebAuthn data.');
}
}
export function base64UrlFromBuffer(value) {
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
let binary = '';
for (let index = 0; index < bytes.length; index += 1) binary += String.fromCharCode(bytes[index]);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
export function parseConnectorRequest(search) {
const params = search instanceof URLSearchParams
? search
: new URLSearchParams(String(search || '').replace(/^\?/, ''));
const parentUrl = safeDecodeURIComponent(params.get('parent'));
const encodedData = params.get('data');
if (!parentUrl) throw new Error('No parent.');
if (!encodedData) throw new Error('No data.');
let parsedParent;
try {
parsedParent = new URL(parentUrl);
} catch (_error) {
throw new Error('Invalid parent.');
}
let webauthnJson;
if (params.get('v') === '1') {
webauthnJson = decodeBase64Utf8(encodedData);
} else {
let payload;
try {
payload = JSON.parse(decodeBase64Utf8(encodedData));
} catch (_error) {
throw new Error('Cannot parse data.');
}
if (!payload || (typeof payload.data !== 'string' && typeof payload.data !== 'object')) {
throw new Error('Cannot parse data.');
}
webauthnJson = typeof payload.data === 'string' ? payload.data : JSON.stringify(payload.data);
}
return {
parentUrl,
parentProtocol: parsedParent.protocol.toLowerCase(),
parentOrigin: parsedParent.origin,
webauthnJson,
buttonText: safeDecodeURIComponent(params.get('btnText')),
awaitingText: safeDecodeURIComponent(params.get('btnAwaitingInteractionText')),
};
}
export function normalizePublicKeyOptions(webauthnJson) {
const source = typeof webauthnJson === 'string' ? JSON.parse(webauthnJson) : webauthnJson;
if (!source || typeof source !== 'object' || !source.challenge) throw new Error('Cannot parse WebAuthn data.');
const publicKey = { ...source, challenge: bytesFromBase64Url(source.challenge) };
if (Array.isArray(source.allowCredentials)) {
publicKey.allowCredentials = source.allowCredentials.map((credential) => ({
...credential,
id: bytesFromBase64Url(credential?.id),
}));
}
return publicKey;
}
export function buildCredentialData(assertedCredential) {
const response = assertedCredential?.response;
if (!assertedCredential || !response?.authenticatorData || !response?.clientDataJSON || !response?.signature) {
throw new Error('The authenticator returned an incomplete response.');
}
return JSON.stringify({
id: assertedCredential.id,
rawId: base64UrlFromBuffer(assertedCredential.rawId),
type: assertedCredential.type,
extensions: typeof assertedCredential.getClientExtensionResults === 'function'
? assertedCredential.getClientExtensionResults()
: {},
response: {
authenticatorData: base64UrlFromBuffer(response.authenticatorData),
clientDataJson: base64UrlFromBuffer(response.clientDataJSON),
signature: base64UrlFromBuffer(response.signature),
},
});
}
function normalizeAllowedOrigin(value) {
try {
const url = new URL(String(value || ''));
return url.protocol && url.host ? `${url.protocol}//${url.host}` : '';
} catch (_error) {
return '';
}
}
function isExtensionOrigin(origin) {
return origin.startsWith('chrome-extension://')
|| origin.startsWith('moz-extension://')
|| origin.startsWith('safari-web-extension://');
}
export function resolveParentChannel(request, connectorOrigin, allowedOrigins = []) {
if (request.parentProtocol === 'file:') {
return { eventOrigin: 'null', targetOrigin: request.parentUrl };
}
const parentOrigin = normalizeAllowedOrigin(request.parentUrl);
if (!parentOrigin) throw new Error('Invalid parent.');
if (parentOrigin === connectorOrigin) {
return { eventOrigin: parentOrigin, targetOrigin: parentOrigin };
}
if (parentOrigin === OFFICIAL_DESKTOP_ORIGIN) {
return { eventOrigin: parentOrigin, targetOrigin: request.parentUrl };
}
const trustedOrigins = allowedOrigins.map(normalizeAllowedOrigin).filter(Boolean);
if (isExtensionOrigin(parentOrigin) && trustedOrigins.includes(parentOrigin)) {
return { eventOrigin: parentOrigin, targetOrigin: parentOrigin };
}
throw new Error('Untrusted parent.');
}
async function loadAllowedParentOrigins() {
try {
const response = await fetch('/api/web-bootstrap', {
headers: { Accept: 'application/json' },
credentials: 'omit',
});
if (!response.ok) return [];
const body = await response.json();
return Array.isArray(body?.webAuthnAllowedOrigins) ? body.webAuthnAllowedOrigins : [];
} catch (_error) {
return [];
}
}
function browserErrorMessage(error) {
return error?.message || String(error || 'WebAuthn failed.');
}
async function initializePage() {
const button = document.getElementById('webauthn-button');
if (!button) return;
let request;
let publicKey;
let channel;
let stopWebAuthn = false;
let sentSuccess = false;
let running = false;
const defaultText = 'Read security key';
const awaitingDefaultText = 'Awaiting security key interaction...';
function setButton(awaiting = false) {
button.textContent = awaiting
? request?.awaitingText || awaitingDefaultText
: request?.buttonText || defaultText;
button.setAttribute('aria-disabled', awaiting ? 'true' : 'false');
button.setAttribute('aria-busy', awaiting ? 'true' : 'false');
button.onclick = awaiting ? null : executeWebAuthn;
}
function post(message) {
window.parent.postMessage(message, channel.targetOrigin);
}
function reportError(error) {
if (channel) post(`error|${browserErrorMessage(error)}`);
setButton(false);
}
async function executeWebAuthn() {
if (running || sentSuccess) return;
if (stopWebAuthn) {
stopWebAuthn = false;
setButton(false);
return;
}
running = true;
setButton(true);
try {
const credential = await navigator.credentials.get({ publicKey });
if (!credential) throw new Error('No security key was selected.');
if (sentSuccess) return;
post(`success|${buildCredentialData(credential)}`);
sentSuccess = true;
} catch (error) {
reportError(error);
} finally {
running = false;
}
}
try {
request = parseConnectorRequest(window.location.search);
publicKey = normalizePublicKeyOptions(request.webauthnJson);
channel = resolveParentChannel(request, window.location.origin, await loadAllowedParentOrigins());
setButton(false);
} catch (error) {
button.textContent = browserErrorMessage(error);
button.setAttribute('aria-disabled', 'true');
return;
}
if (!navigator.credentials || typeof navigator.credentials.get !== 'function' || !window.PublicKeyCredential) {
reportError(new Error('WebAuthn is not supported in this browser.'));
return;
}
window.addEventListener('message', (event) => {
if (event.source !== window.parent || event.origin !== channel.eventOrigin) return;
if (event.data === 'stop') {
stopWebAuthn = true;
setButton(false);
} else if (event.data === 'start' && stopWebAuthn) {
stopWebAuthn = false;
void executeWebAuthn();
}
});
post('info|ready');
const isSafari = navigator.userAgent.includes(' Safari/') && !navigator.userAgent.includes('Chrome');
if (!isSafari) void executeWebAuthn();
}
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => void initializePage(), { once: true });
} else {
void initializePage();
}
}
@@ -0,0 +1,174 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#f6f8fb" />
<meta name="color-scheme" content="light" />
<meta name="referrer" content="no-referrer" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self'; connect-src 'none'; base-uri 'none'; form-action 'none'"
/>
<title>NodeWarden WebAuthn Connector</title>
<style>
:root {
color-scheme: light;
--primary: #2563eb;
--primary-strong: #1d4ed8;
--text: #101828;
--muted: #667085;
--line: #d8e0ec;
--panel: #ffffff;
--surface: #f6f8fb;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
background: var(--surface);
color: var(--text);
}
main {
display: grid;
min-height: 100vh;
min-height: 100svh;
place-items: center;
padding: max(28px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right)) max(28px, env(safe-area-inset-bottom)) max(18px, env(safe-area-inset-left));
}
.connector-card {
width: min(100%, 430px);
border: 1px solid var(--line);
border-radius: 18px;
background: var(--panel);
box-shadow: 0 18px 44px rgba(16, 24, 40, 0.10);
padding: 28px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 28px;
}
.brand img {
width: 44px;
height: 44px;
object-fit: contain;
}
.brand strong {
font-size: 18px;
line-height: 1;
}
h1 {
margin: 0 0 8px;
font-size: 26px;
line-height: 1.2;
}
p {
margin: 0;
color: var(--muted);
line-height: 1.55;
}
.form {
display: grid;
gap: 16px;
margin-top: 24px;
}
button {
min-height: 48px;
width: 100%;
border: 1px solid var(--primary);
border-radius: 10px;
background: var(--primary);
color: #fff;
cursor: pointer;
font: inherit;
font-weight: 800;
transition: background-color 160ms ease, border-color 160ms ease, transform 120ms ease;
}
button:hover:not(:disabled)[aria-disabled="false"] {
background: var(--primary-strong);
border-color: var(--primary-strong);
}
button:active:not(:disabled)[aria-disabled="false"] {
transform: translateY(1px);
}
button:disabled,
button[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.62;
}
button[data-state="return"] {
cursor: pointer;
opacity: 1;
}
.msg {
display: none;
border-radius: 10px;
padding: 11px 12px;
font-size: 14px;
line-height: 1.45;
}
.msg.show {
display: block;
}
.msg.info {
border: 1px solid #bfdbfe;
background: #eff6ff;
color: #1e40af;
}
.msg.error {
border: 1px solid #fecaca;
background: #fef2f2;
color: #991b1b;
}
.msg.success {
border: 1px solid #bbf7d0;
background: #f0fdf4;
color: #166534;
}
</style>
</head>
<body>
<main>
<section class="connector-card" aria-labelledby="webauthn-header">
<div class="brand">
<img src="/nodewarden-logo.svg" alt="NodeWarden" />
<strong>NodeWarden</strong>
</div>
<h1 id="webauthn-header">Verify your identity</h1>
<p id="webauthn-copy">Use your security key to finish two-step verification.</p>
<div class="form">
<div id="webauthn-status" class="msg" role="status" aria-live="polite" hidden></div>
<button id="webauthn-button" type="button" data-state="loading" aria-busy="true" aria-disabled="true">
Preparing passkey…
</button>
</div>
</section>
</main>
<script type="module" src="/webauthn-mobile-connector.js"></script>
</body>
</html>
+272
View File
@@ -0,0 +1,272 @@
const CUSTOM_SCHEME_CALLBACK = 'bitwarden://webauthn-callback';
const APP_LINK_HOSTS = ['bitwarden.com', 'bitwarden.eu', 'bitwarden.pw', 'bitwarden-gov.com'];
function safeDecodeURIComponent(value) {
let decoded = String(value || '');
for (let index = 0; index < 2 && /%[0-9a-f]{2}/i.test(decoded); index += 1) {
try {
decoded = decodeURIComponent(decoded);
} catch (_error) {
break;
}
}
return decoded;
}
export function decodeBase64Utf8(value) {
let normalized = String(value || '').replace(/ /g, '+').replace(/-/g, '+').replace(/_/g, '/');
normalized += '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
let binary;
try {
binary = atob(normalized);
} catch (_error) {
throw new Error('The WebAuthn challenge is not valid Base64.');
}
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
if (typeof TextDecoder !== 'undefined') return new TextDecoder().decode(bytes);
return decodeURIComponent(Array.from(bytes, (byte) => `%${byte.toString(16).padStart(2, '0')}`).join(''));
}
export function bytesFromBase64Url(value) {
let normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
normalized += '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
try {
return Uint8Array.from(atob(normalized), (character) => character.charCodeAt(0));
} catch (_error) {
throw new Error('The WebAuthn challenge contains invalid binary data.');
}
}
export function base64UrlFromBuffer(value) {
if (value == null) return undefined;
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
let binary = '';
for (let index = 0; index < bytes.length; index += 1) binary += String.fromCharCode(bytes[index]);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function officialAppLinkHost(hostname) {
const normalized = String(hostname || '').toLowerCase();
return APP_LINK_HOSTS.find((host) => normalized === host || normalized.endsWith(`.${host}`)) || 'bitwarden.com';
}
export function resolveMobileCallbackUri({ deeplinkScheme, payload, hostname, legacyMobile = false }) {
// Match Bitwarden's connector protocol: the scheme parameter governs the
// callback shape. Any non-HTTPS scheme resolves to Bitwarden's fixed custom
// scheme; a client-provided callbackUri is only a mobile-flow signal.
if (deeplinkScheme) {
return String(deeplinkScheme).toLowerCase() === 'https'
? `https://${officialAppLinkHost(hostname)}/webauthn-callback`
: CUSTOM_SCHEME_CALLBACK;
}
return payload?.mobile === true || payload?.callbackUri != null || legacyMobile
? CUSTOM_SCHEME_CALLBACK
: null;
}
export function parseConnectorRequest(search, hostname = '') {
const params = search instanceof URLSearchParams
? search
: new URLSearchParams(String(search || '').replace(/^\?/, ''));
const encodedData = params.get('data');
if (!encodedData) throw new Error('No WebAuthn challenge was provided.');
const version = params.get('v');
let payload = null;
let webauthnJson;
let headerText;
let buttonText;
let returnButtonText;
let awaitingText;
if (version === '1') {
webauthnJson = decodeBase64Utf8(encodedData);
headerText = params.get('headerText');
buttonText = params.get('btnText');
returnButtonText = params.get('btnReturnText');
awaitingText = params.get('btnAwaitingInteractionText');
} else {
try {
payload = JSON.parse(decodeBase64Utf8(encodedData));
} catch (_error) {
throw new Error('The WebAuthn challenge could not be decoded.');
}
if (!payload || (typeof payload.data !== 'string' && typeof payload.data !== 'object')) {
throw new Error('The WebAuthn challenge is incomplete.');
}
webauthnJson = typeof payload.data === 'string' ? payload.data : JSON.stringify(payload.data);
headerText = payload.headerText;
buttonText = payload.btnText;
returnButtonText = payload.btnReturnText;
awaitingText = payload.btnAwaitingInteractionText;
}
const callbackUri = resolveMobileCallbackUri({
deeplinkScheme: params.get('deeplinkScheme'),
payload,
hostname,
legacyMobile: params.get('client') === 'mobile',
});
if (!callbackUri) throw new Error('No supported mobile return target was provided.');
return {
callbackUri,
webauthnJson,
headerText: safeDecodeURIComponent(headerText),
buttonText: safeDecodeURIComponent(buttonText),
returnButtonText: safeDecodeURIComponent(returnButtonText),
awaitingText: safeDecodeURIComponent(awaitingText),
};
}
export function normalizePublicKeyOptions(webauthnJson) {
const source = typeof webauthnJson === 'string' ? JSON.parse(webauthnJson) : webauthnJson;
if (!source || typeof source !== 'object' || !source.challenge) {
throw new Error('The WebAuthn challenge is invalid.');
}
const publicKey = { ...source, challenge: bytesFromBase64Url(source.challenge) };
if (Array.isArray(source.allowCredentials)) {
publicKey.allowCredentials = source.allowCredentials.map((credential) => ({
...credential,
id: bytesFromBase64Url(credential?.id),
}));
}
return publicKey;
}
export function buildCredentialData(assertedCredential) {
const response = assertedCredential?.response;
if (!assertedCredential || !response?.authenticatorData || !response?.clientDataJSON || !response?.signature) {
throw new Error('The authenticator returned an incomplete response.');
}
const extensions = typeof assertedCredential.getClientExtensionResults === 'function'
? assertedCredential.getClientExtensionResults()
: {};
const clientData = base64UrlFromBuffer(response.clientDataJSON);
return JSON.stringify({
id: assertedCredential.id,
rawId: base64UrlFromBuffer(assertedCredential.rawId),
type: assertedCredential.type,
extensions,
response: {
authenticatorData: base64UrlFromBuffer(response.authenticatorData),
clientDataJson: clientData,
signature: base64UrlFromBuffer(response.signature),
},
});
}
export function buildCallbackUrl(callbackUri, key, value) {
const separator = String(callbackUri).includes('?') ? '&' : '?';
return `${callbackUri}${separator}${encodeURIComponent(key)}=${encodeURIComponent(String(value || ''))}`;
}
function translations(locale) {
const normalized = String(locale || 'en').toLowerCase();
if (normalized.startsWith('zh-tw') || normalized.startsWith('zh-hk')) {
return {
title: '兩步驟驗證', copy: '使用通行密鑰或安全金鑰完成登入。', button: '使用通行密鑰驗證',
awaiting: '請依照系統提示完成驗證…', returning: '正在返回 Bitwarden…', returnButton: '返回 Bitwarden',
unsupported: '此瀏覽器不支援通行密鑰。', cancelled: '驗證已取消,請重試。',
};
}
if (normalized.startsWith('zh')) {
return {
title: '两步验证', copy: '使用通行密钥或安全密钥完成登录。', button: '使用通行密钥验证',
awaiting: '请按照系统提示完成验证…', returning: '正在返回 Bitwarden…', returnButton: '返回 Bitwarden',
unsupported: '此浏览器不支持通行密钥。', cancelled: '验证已取消,请重试。',
};
}
return {
title: 'Two-step verification', copy: 'Use your passkey or security key to finish signing in.',
button: 'Authenticate with passkey', awaiting: 'Follow the system prompt to continue…',
returning: 'Returning to Bitwarden…', returnButton: 'Return to Bitwarden',
unsupported: 'This browser does not support passkeys.', cancelled: 'Verification was cancelled. Please try again.',
};
}
function browserErrorMessage(error, text) {
if (error?.name === 'NotAllowedError' || error?.name === 'AbortError') return text.cancelled;
return error?.message || String(error || 'WebAuthn failed.');
}
function initializePage() {
const button = document.getElementById('webauthn-button');
const header = document.getElementById('webauthn-header');
const copy = document.getElementById('webauthn-copy');
const status = document.getElementById('webauthn-status');
if (!button || !header || !copy || !status) return;
const text = translations(navigator.languages?.[0] || navigator.language);
document.documentElement.lang = navigator.languages?.[0] || navigator.language || 'en';
copy.textContent = text.copy;
let request;
let publicKey;
let completed = false;
let returnUri = '';
function setButton(label, state, handler) {
button.textContent = label;
button.dataset.state = state;
button.disabled = state === 'unavailable';
button.setAttribute('aria-disabled', handler ? 'false' : 'true');
button.setAttribute('aria-busy', state === 'waiting' ? 'true' : 'false');
button.onclick = handler;
}
function setStatus(kind, message) {
status.hidden = !message;
status.dataset.kind = kind;
status.textContent = message || '';
status.className = message ? `msg show ${kind}` : 'msg';
}
function navigate(uri) {
returnUri = uri;
window.location.replace(uri);
setButton(request?.returnButtonText || text.returnButton, 'return', () => window.location.replace(returnUri));
}
function handoffError(message) {
setStatus('error', message);
if (request?.callbackUri) navigate(buildCallbackUrl(request.callbackUri, 'error', message));
}
async function executeWebAuthn() {
if (completed || button.dataset.state === 'waiting') return;
setStatus('info', request.awaitingText || text.awaiting);
setButton(request.awaitingText || text.awaiting, 'waiting', null);
try {
const credential = await navigator.credentials.get({ publicKey });
if (!credential) throw new Error('No passkey was selected.');
const data = buildCredentialData(credential);
completed = true;
setStatus('success', text.returning);
navigate(buildCallbackUrl(request.callbackUri, 'data', data));
} catch (error) {
setButton(request.buttonText || text.button, 'ready', executeWebAuthn);
handoffError(browserErrorMessage(error, text));
}
}
try {
request = parseConnectorRequest(window.location.search, window.location.hostname);
publicKey = normalizePublicKeyOptions(request.webauthnJson);
header.textContent = request.headerText || text.title;
setButton(request.buttonText || text.button, 'ready', executeWebAuthn);
} catch (error) {
header.textContent = text.title;
setStatus('error', browserErrorMessage(error, text));
setButton(text.button, 'unavailable', null);
}
if (!navigator.credentials || typeof navigator.credentials.get !== 'function' || !window.PublicKeyCredential) {
handoffError(text.unsupported);
if (!request?.callbackUri) setButton(text.button, 'unavailable', null);
}
}
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initializePage, { once: true });
else initializePage();
}
+239 -168
View File
@@ -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>
+17 -5
View File
@@ -1,4 +1,12 @@
import { bytesToBase64, decryptBw, encryptBw, hkdfExpand, pbkdf2 } from '../crypto'; import {
bytesToBase64,
decryptBw,
encryptBw,
hkdfExpand,
pbkdf2,
requireWebCrypto,
WebCryptoUnavailableError,
} from '../crypto';
import { t, translateServerError } from '../i18n'; import { t, translateServerError } from '../i18n';
import type { AuthorizedDevice } from '../types'; import type { AuthorizedDevice } from '../types';
import type { import type {
@@ -428,14 +436,15 @@ export async function registerAccount(args: {
}): Promise<{ ok: true } | { ok: false; message: string }> { }): Promise<{ ok: true } | { ok: false; message: string }> {
try { try {
const { email, name, password, masterPasswordHint, inviteCode, fallbackIterations } = args; const { email, name, password, masterPasswordHint, inviteCode, fallbackIterations } = args;
const webCrypto = requireWebCrypto();
const masterKey = await pbkdf2(password, email, fallbackIterations, 32); const masterKey = await pbkdf2(password, email, fallbackIterations, 32);
const masterHash = await pbkdf2(masterKey, password, 1, 32); const masterHash = await pbkdf2(masterKey, password, 1, 32);
const encKey = await hkdfExpand(masterKey, 'enc', 32); const encKey = await hkdfExpand(masterKey, 'enc', 32);
const macKey = await hkdfExpand(masterKey, 'mac', 32); const macKey = await hkdfExpand(masterKey, 'mac', 32);
const sym = crypto.getRandomValues(new Uint8Array(64)); const sym = webCrypto.getRandomValues(new Uint8Array(64));
const encryptedVaultKey = await encryptBw(sym, encKey, macKey); const encryptedVaultKey = await encryptBw(sym, encKey, macKey);
const keyPair = await crypto.subtle.generateKey( const keyPair = await webCrypto.subtle.generateKey(
{ {
name: 'RSA-OAEP', name: 'RSA-OAEP',
modulusLength: 2048, modulusLength: 2048,
@@ -445,8 +454,8 @@ export async function registerAccount(args: {
true, true,
['encrypt', 'decrypt'] ['encrypt', 'decrypt']
); );
const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey)); const publicKey = new Uint8Array(await webCrypto.subtle.exportKey('spki', keyPair.publicKey));
const privateKey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey)); const privateKey = new Uint8Array(await webCrypto.subtle.exportKey('pkcs8', keyPair.privateKey));
const encryptedPrivateKey = await encryptBw(privateKey, sym.slice(0, 32), sym.slice(32, 64)); const encryptedPrivateKey = await encryptBw(privateKey, sym.slice(0, 32), sym.slice(32, 64));
const resp = await fetch('/api/accounts/register', { const resp = await fetch('/api/accounts/register', {
@@ -474,6 +483,9 @@ export async function registerAccount(args: {
} }
return { ok: true }; return { ok: true };
} catch (error) { } catch (error) {
if (error instanceof WebCryptoUnavailableError) {
return { ok: false, message: t('txt_web_crypto_unavailable') };
}
return { ok: false, message: error instanceof Error ? translateServerError(error.message, error.message) : t('txt_register_failed') }; return { ok: false, message: error instanceof Error ? translateServerError(error.message, error.message) : t('txt_register_failed') };
} }
} }
+52 -17
View File
@@ -1,3 +1,34 @@
export const WEB_CRYPTO_UNAVAILABLE_MESSAGE =
'Secure browser cryptography is unavailable. Open NodeWarden over HTTPS in a supported browser.';
export class WebCryptoUnavailableError extends Error {
constructor() {
super(WEB_CRYPTO_UNAVAILABLE_MESSAGE);
this.name = 'WebCryptoUnavailableError';
}
}
interface WebCryptoEnvironment {
crypto?: Crypto;
isSecureContext?: boolean;
}
export function requireWebCrypto(
environment: WebCryptoEnvironment = globalThis as unknown as WebCryptoEnvironment
): Crypto {
const cryptoApi = environment.crypto;
if (
environment.isSecureContext === false ||
!cryptoApi ||
typeof cryptoApi.getRandomValues !== 'function' ||
!cryptoApi.subtle ||
typeof cryptoApi.subtle.importKey !== 'function'
) {
throw new WebCryptoUnavailableError();
}
return cryptoApi;
}
export function bytesToBase64(bytes: Uint8Array): string { export function bytesToBase64(bytes: Uint8Array): string {
let s = ''; let s = '';
for (let i = 0; i < bytes.length; i += 1) s += String.fromCharCode(bytes[i]); for (let i = 0; i < bytes.length; i += 1) s += String.fromCharCode(bytes[i]);
@@ -24,7 +55,7 @@ export function toBufferSource(bytes: Uint8Array): ArrayBuffer {
export async function sha256Base64(value: string): Promise<string> { export async function sha256Base64(value: string): Promise<string> {
const bytes = new TextEncoder().encode(value); const bytes = new TextEncoder().encode(value);
const hash = await crypto.subtle.digest('SHA-256', toBufferSource(bytes)); const hash = await requireWebCrypto().subtle.digest('SHA-256', toBufferSource(bytes));
return bytesToBase64(new Uint8Array(hash)); return bytesToBase64(new Uint8Array(hash));
} }
@@ -51,7 +82,7 @@ function getHmacSha256Key(keyBytes: Uint8Array): Promise<CryptoKey> {
return getCachedCryptoKey( return getCachedCryptoKey(
hmacSha256KeyCache, hmacSha256KeyCache,
keyBytes, keyBytes,
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) () => requireWebCrypto().subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
); );
} }
@@ -59,7 +90,7 @@ function getAesCbcEncryptKey(keyBytes: Uint8Array): Promise<CryptoKey> {
return getCachedCryptoKey( return getCachedCryptoKey(
aesCbcEncryptKeyCache, aesCbcEncryptKeyCache,
keyBytes, keyBytes,
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['encrypt']) () => requireWebCrypto().subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['encrypt'])
); );
} }
@@ -67,7 +98,7 @@ function getAesCbcDecryptKey(keyBytes: Uint8Array): Promise<CryptoKey> {
return getCachedCryptoKey( return getCachedCryptoKey(
aesCbcDecryptKeyCache, aesCbcDecryptKeyCache,
keyBytes, keyBytes,
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['decrypt']) () => requireWebCrypto().subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['decrypt'])
); );
} }
@@ -88,8 +119,9 @@ export async function pbkdf2(
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const pwdBytes = typeof passwordOrBytes === 'string' ? new TextEncoder().encode(passwordOrBytes) : passwordOrBytes; const pwdBytes = typeof passwordOrBytes === 'string' ? new TextEncoder().encode(passwordOrBytes) : passwordOrBytes;
const saltBytes = typeof saltOrBytes === 'string' ? new TextEncoder().encode(saltOrBytes) : saltOrBytes; const saltBytes = typeof saltOrBytes === 'string' ? new TextEncoder().encode(saltOrBytes) : saltOrBytes;
const key = await crypto.subtle.importKey('raw', toBufferSource(pwdBytes), 'PBKDF2', false, ['deriveBits']); const subtle = requireWebCrypto().subtle;
const bits = await crypto.subtle.deriveBits( const key = await subtle.importKey('raw', toBufferSource(pwdBytes), 'PBKDF2', false, ['deriveBits']);
const bits = await subtle.deriveBits(
{ name: 'PBKDF2', hash: 'SHA-256', salt: toBufferSource(saltBytes), iterations }, { name: 'PBKDF2', hash: 'SHA-256', salt: toBufferSource(saltBytes), iterations },
key, key,
keyLen * 8 keyLen * 8
@@ -99,7 +131,8 @@ export async function pbkdf2(
export async function hkdfExpand(prk: Uint8Array, info: string, length: number): Promise<Uint8Array> { export async function hkdfExpand(prk: Uint8Array, info: string, length: number): Promise<Uint8Array> {
const infoBytes = new TextEncoder().encode(info || ''); const infoBytes = new TextEncoder().encode(info || '');
const key = await crypto.subtle.importKey('raw', toBufferSource(prk), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); const subtle = requireWebCrypto().subtle;
const key = await subtle.importKey('raw', toBufferSource(prk), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const result = new Uint8Array(length); const result = new Uint8Array(length);
let previous = new Uint8Array(0); let previous = new Uint8Array(0);
let offset = 0; let offset = 0;
@@ -110,7 +143,7 @@ export async function hkdfExpand(prk: Uint8Array, info: string, length: number):
input.set(previous, 0); input.set(previous, 0);
input.set(infoBytes, previous.length); input.set(infoBytes, previous.length);
input[input.length - 1] = counter & 0xff; input[input.length - 1] = counter & 0xff;
previous = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(input))); previous = new Uint8Array(await subtle.sign('HMAC', key, toBufferSource(input)));
const copyLen = Math.min(previous.length, length - offset); const copyLen = Math.min(previous.length, length - offset);
result.set(previous.slice(0, copyLen), offset); result.set(previous.slice(0, copyLen), offset);
offset += copyLen; offset += copyLen;
@@ -134,28 +167,29 @@ export async function hkdf(
info: toBufferSource(infoBytes), info: toBufferSource(infoBytes),
hash: 'SHA-256', hash: 'SHA-256',
}; };
const key = await crypto.subtle.importKey('raw', toBufferSource(ikm), 'HKDF', false, ['deriveBits']); const subtle = requireWebCrypto().subtle;
const bits = await crypto.subtle.deriveBits(params, key, outputByteSize * 8); const key = await subtle.importKey('raw', toBufferSource(ikm), 'HKDF', false, ['deriveBits']);
const bits = await subtle.deriveBits(params, key, outputByteSize * 8);
return new Uint8Array(bits); return new Uint8Array(bits);
} }
async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise<Uint8Array> { async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise<Uint8Array> {
const key = await getHmacSha256Key(keyBytes); const key = await getHmacSha256Key(keyBytes);
return new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(dataBytes))); return new Uint8Array(await requireWebCrypto().subtle.sign('HMAC', key, toBufferSource(dataBytes)));
} }
async function encryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> { async function encryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
const cryptoKey = await getAesCbcEncryptKey(key); const cryptoKey = await getAesCbcEncryptKey(key);
return new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data))); return new Uint8Array(await requireWebCrypto().subtle.encrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
} }
async function decryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> { async function decryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
const cryptoKey = await getAesCbcDecryptKey(key); const cryptoKey = await getAesCbcDecryptKey(key);
return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data))); return new Uint8Array(await requireWebCrypto().subtle.decrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
} }
export async function encryptBwFileData(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<Uint8Array> { export async function encryptBwFileData(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<Uint8Array> {
const iv = crypto.getRandomValues(new Uint8Array(16)); const iv = requireWebCrypto().getRandomValues(new Uint8Array(16));
const cipher = await encryptAesCbc(data, encKey, iv); const cipher = await encryptAesCbc(data, encKey, iv);
const mac = await hmacSha256(macKey, concatBytes(iv, cipher)); const mac = await hmacSha256(macKey, concatBytes(iv, cipher));
const out = new Uint8Array(1 + iv.length + mac.length + cipher.length); const out = new Uint8Array(1 + iv.length + mac.length + cipher.length);
@@ -179,7 +213,7 @@ export async function decryptBwFileData(encrypted: Uint8Array, encKey: Uint8Arra
} }
export async function encryptBw(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<string> { export async function encryptBw(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(16)); const iv = requireWebCrypto().getRandomValues(new Uint8Array(16));
const cipher = await encryptAesCbc(data, encKey, iv); const cipher = await encryptAesCbc(data, encKey, iv);
const mac = await hmacSha256(macKey, concatBytes(iv, cipher)); const mac = await hmacSha256(macKey, concatBytes(iv, cipher));
return `2.${bytesToBase64(iv)}|${bytesToBase64(cipher)}|${bytesToBase64(mac)}`; return `2.${bytesToBase64(iv)}|${bytesToBase64(cipher)}|${bytesToBase64(mac)}`;
@@ -556,8 +590,9 @@ export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now())
message[i] = c & 0xff; message[i] = c & 0xff;
c = Math.floor(c / 256); c = Math.floor(c / 256);
} }
const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: algorithm }, false, ['sign']); const subtle = requireWebCrypto().subtle;
const hs = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(message))); const key = await subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: algorithm }, false, ['sign']);
const hs = new Uint8Array(await subtle.sign('HMAC', key, toBufferSource(message)));
const offset = hs[hs.length - 1] & 0x0f; const offset = hs[hs.length - 1] & 0x0f;
const bin = ((hs[offset] & 0x7f) << 24) | ((hs[offset + 1] & 0xff) << 16) | ((hs[offset + 2] & 0xff) << 8) | (hs[offset + 3] & 0xff); const bin = ((hs[offset] & 0x7f) << 24) | ((hs[offset + 1] & 0xff) << 16) | ((hs[offset + 2] & 0xff) << 8) | (hs[offset + 3] & 0xff);
let code = (bin % (10 ** digits)).toString().padStart(digits, '0'); let code = (bin % (10 ** digits)).toString().padStart(digits, '0');
+1
View File
@@ -238,6 +238,7 @@ export function translateServerError(message: string | null | undefined, fallbac
'WebDAV server URL is required': 'txt_backup_error_webdav_url_required', 'WebDAV server URL is required': 'txt_backup_error_webdav_url_required',
'WebDAV server URL must start with http:// or https://': 'txt_backup_error_webdav_url_protocol', 'WebDAV server URL must start with http:// or https://': 'txt_backup_error_webdav_url_protocol',
'WebDAV username is required': 'txt_backup_error_webdav_username_required', 'WebDAV username is required': 'txt_backup_error_webdav_username_required',
'Secure browser cryptography is unavailable. Open NodeWarden over HTTPS in a supported browser.': 'txt_web_crypto_unavailable',
'masterPasswordHash is required': 'txt_server_error_master_password_hash_required', 'masterPasswordHash is required': 'txt_server_error_master_password_hash_required',
'masterPasswordHash or userVerificationToken is required': 'txt_server_error_master_password_or_verification_required', 'masterPasswordHash or userVerificationToken is required': 'txt_server_error_master_password_or_verification_required',
}[normalized]; }[normalized];
+5
View File
@@ -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",
+35
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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 dutilisateur", "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 dalias", "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 lalias.", "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 nest pas disponible. Ouvrez NodeWarden via HTTPS dans un navigateur compatible.",
"txt_create_folder": "Créer un dossier", "txt_create_folder": "Créer un dossier",
"txt_create_folder_failed": "La création du dossier a échoué", "txt_create_folder_failed": "La création du dossier a échoué",
"txt_create_item_failed": "La création de l'élément a échoué", "txt_create_item_failed": "La création de l'élément a échoué",
+5
View File
@@ -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 lindirizzo email o il dominio per generare un alias.", "txt_generator_email_description": "I dati restano su questo dispositivo e servono solo a comporre lalias.", "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",
+5
View File
@@ -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": "Создать элемент не удалось",
+5
View File
@@ -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",
+5
View File
@@ -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": "创建项目失败",
+5
View File
@@ -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": "創建項目失敗",
+271
View File
@@ -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)));
}
+141
View File
@@ -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);
}
+4 -4
View File
@@ -369,7 +369,7 @@
.not-found-code { .not-found-code {
@apply rounded-full px-3 py-1 text-sm font-extrabold; @apply rounded-full px-3 py-1 text-sm font-extrabold;
background: #eef4ff; background: #eef4ff;
color: #1d4ed8; color: var(--primary-hover);
} }
.not-found-copy { .not-found-copy {
@@ -574,7 +574,7 @@
.jwt-inline-link { .jwt-inline-link {
@apply font-bold no-underline; @apply font-bold no-underline;
color: #1d4ed8; color: var(--primary-hover);
} }
.jwt-inline-link:hover { .jwt-inline-link:hover {
@@ -613,7 +613,7 @@
.standalone-footer a { .standalone-footer a {
@apply font-bold no-underline; @apply font-bold no-underline;
color: #1d4ed8; color: var(--primary-hover);
} }
.standalone-footer a:hover { .standalone-footer a:hover {
@@ -622,5 +622,5 @@
.standalone-version { .standalone-version {
@apply font-bold; @apply font-bold;
color: #1d4ed8; color: var(--primary-hover);
} }
+1 -1
View File
@@ -220,7 +220,7 @@
} }
:root[data-theme='dark'] .card-brand-icon { :root[data-theme='dark'] .card-brand-icon {
color: #bfdbfe; color: var(--primary-strong);
background: linear-gradient(180deg, #1f2937 0%, #111827 100%); background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
border-color: color-mix(in srgb, var(--primary) 30%, var(--line)); border-color: color-mix(in srgb, var(--primary) 30%, var(--line));
} }
+11 -4
View File
@@ -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; } }
+12 -10
View File
@@ -99,7 +99,7 @@
@apply inline-flex h-[22px] w-[22px] shrink-0 cursor-pointer items-center justify-center rounded-full p-0 text-[13px] font-extrabold leading-none; @apply inline-flex h-[22px] w-[22px] shrink-0 cursor-pointer items-center justify-center rounded-full p-0 text-[13px] font-extrabold leading-none;
border: 1px solid #bfd1f3; border: 1px solid #bfd1f3;
background: #eef4ff; background: #eef4ff;
color: #1d4ed8; color: var(--primary-hover);
} }
.backup-help-trigger:hover, .backup-help-trigger:hover,
@@ -194,7 +194,7 @@
} }
.backup-recommendation-step a { .backup-recommendation-step a {
color: #1d4ed8; color: var(--primary-hover);
font-weight: 700; font-weight: 700;
text-decoration: underline; text-decoration: underline;
text-underline-offset: 2px; text-underline-offset: 2px;
@@ -316,14 +316,14 @@
} }
.backup-interval-preset:hover:not(:disabled) { .backup-interval-preset:hover:not(:disabled) {
border-color: #2563eb; border-color: var(--primary);
color: #2563eb; color: var(--primary);
background: #eff6ff; background: #eff6ff;
} }
.backup-interval-preset.active { .backup-interval-preset.active {
border-color: #2563eb; border-color: var(--primary);
background: #2563eb; background: var(--primary);
color: #fff; color: #fff;
} }
@@ -1786,7 +1786,7 @@
} }
.restore-progress-item.active { .restore-progress-item.active {
color: #1d4ed8; color: var(--primary-hover);
} }
.restore-progress-item.done { .restore-progress-item.done {
@@ -1799,7 +1799,7 @@
} }
.restore-progress-item.active .restore-progress-dot { .restore-progress-item.active .restore-progress-dot {
background: #1d4ed8; background: var(--primary-hover);
} }
.restore-progress-item.done .restore-progress-dot { .restore-progress-item.done .restore-progress-dot {
@@ -1871,7 +1871,7 @@
.authorized-device-checkbox { .authorized-device-checkbox {
width: 16px; width: 16px;
height: 16px; height: 16px;
accent-color: #2563eb; accent-color: var(--primary);
} }
.authorized-devices-table td:first-child { .authorized-devices-table td:first-child {
@@ -1879,7 +1879,9 @@
} }
.authorized-devices-actions { .authorized-devices-actions {
flex-wrap: nowrap; /* Wrap instead of clipping: four non-shrinking buttons overflow the
fixed 26% actions column at common widths, hiding Delete entirely. */
flex-wrap: wrap;
gap: 6px; gap: 6px;
} }
+7 -5
View File
@@ -47,7 +47,7 @@
} }
.folder-add-btn:hover { .folder-add-btn:hover {
color: #1d4ed8; color: var(--primary-hover);
} }
.search-input { .search-input {
@@ -161,7 +161,7 @@
} }
.folder-sort-btn:hover { .folder-sort-btn:hover {
color: #1d4ed8; color: var(--primary-hover);
background: #dbeafe; background: #dbeafe;
transform: scale(1.06); transform: scale(1.06);
} }
@@ -230,7 +230,7 @@ select.input.duplicate-mode-toolbar-select {
} }
.list-head { .list-head {
@apply mb-1.5 flex items-center gap-2; @apply mb-1.5 flex flex-wrap items-center gap-2;
min-height: 34px; min-height: 34px;
} }
@@ -860,7 +860,9 @@ select.input.duplicate-mode-toolbar-select {
.kv-row { .kv-row {
@apply grid items-center gap-2.5 py-2.5; @apply grid items-center gap-2.5 py-2.5;
grid-template-columns: minmax(0px, 80px) minmax(0, 1fr) auto; /* Floor the value column so the auto-sized actions column wraps its
buttons instead of collapsing the value to 0 width. */
grid-template-columns: minmax(0px, 80px) minmax(min(35%, 140px), 1fr) auto;
border-bottom: 1px solid rgba(154, 172, 205, 0.22); border-bottom: 1px solid rgba(154, 172, 205, 0.22);
} }
@@ -962,7 +964,7 @@ select.input.duplicate-mode-toolbar-select {
} }
.totp-ring-progress { .totp-ring-progress {
stroke: #2563eb; stroke: var(--primary);
stroke-linecap: round; stroke-linecap: round;
transition: stroke-dashoffset 260ms linear, stroke 200ms ease; transition: stroke-dashoffset 260ms linear, stroke 200ms ease;
} }
+49 -59
View File
@@ -102,6 +102,28 @@ async function appShellNavigation(request) {
); );
} }
async function connectorNavigation(request) {
const runtimeCache = await caches.open(RUNTIME_CACHE);
try {
const response = await fetch(request);
if (isCacheableResponse(response)) {
await runtimeCache.put(request, response.clone());
await trimRuntimeCache(runtimeCache, 120);
}
return response;
} catch {
const shellCache = await caches.open(APP_SHELL_CACHE);
const cached =
(await shellCache.match(request, { ignoreSearch: true }))
|| (await runtimeCache.match(request, { ignoreSearch: true }))
|| (await matchLegacyRuntimeCache(request));
return cached || new Response('WebAuthn connector is unavailable while offline.', {
status: 503,
headers: { 'Content-Type': 'text/plain; charset=UTF-8' },
});
}
}
async function trimRuntimeCache(cache, maxEntries) { async function trimRuntimeCache(cache, maxEntries) {
const keys = await cache.keys(); const keys = await cache.keys();
if (keys.length <= maxEntries) return; if (keys.length <= maxEntries) return;
@@ -145,6 +167,13 @@ self.addEventListener('fetch', (event) => {
const url = new URL(request.url); const url = new URL(request.url);
if (NEVER_CACHE_PATH_RE.test(url.pathname)) return; if (NEVER_CACHE_PATH_RE.test(url.pathname)) return;
// Connector navigations are protocol pages, not application routes. They must
// never be replaced with the SPA shell, even when the device is offline.
if (url.pathname.endsWith('-connector.html')) {
event.respondWith(connectorNavigation(request));
return;
}
if (request.mode === 'navigate') { if (request.mode === 'navigate') {
event.respondWith(appShellNavigation(request)); event.respondWith(appShellNavigation(request));
if (navigator.onLine !== false) { if (navigator.onLine !== false) {
@@ -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;
}, },
}, },
}, },
+1
View File
@@ -8,6 +8,7 @@ command = "npm run build"
[assets] [assets]
binding = "ASSETS" binding = "ASSETS"
directory = "./dist" directory = "./dist"
html_handling = "none"
not_found_handling = "single-page-application" not_found_handling = "single-page-application"
run_worker_first = true run_worker_first = true
+1
View File
@@ -8,6 +8,7 @@ command = "npm run build"
[assets] [assets]
binding = "ASSETS" binding = "ASSETS"
directory = "./dist" directory = "./dist"
html_handling = "none"
not_found_handling = "single-page-application" not_found_handling = "single-page-application"
run_worker_first = true run_worker_first = true