From 82d9f61163f6d29803e3a3264b8f986dfa992ebc Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 23 Jul 2026 00:54:52 +0800 Subject: [PATCH] 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 --- package.json | 1 + scripts/web-crypto-availability.test.ts | 84 +++++++++++++++++++++++++ webapp/src/lib/api/auth.ts | 22 +++++-- webapp/src/lib/crypto.ts | 69 +++++++++++++++----- webapp/src/lib/i18n.ts | 1 + webapp/src/lib/i18n/locales/de.ts | 1 + webapp/src/lib/i18n/locales/en.ts | 1 + webapp/src/lib/i18n/locales/es.ts | 1 + webapp/src/lib/i18n/locales/fi.ts | 1 + webapp/src/lib/i18n/locales/fr.ts | 1 + webapp/src/lib/i18n/locales/it.ts | 1 + webapp/src/lib/i18n/locales/ru.ts | 1 + webapp/src/lib/i18n/locales/sv.ts | 1 + webapp/src/lib/i18n/locales/zh-CN.ts | 1 + webapp/src/lib/i18n/locales/zh-TW.ts | 1 + 15 files changed, 165 insertions(+), 22 deletions(-) create mode 100644 scripts/web-crypto-availability.test.ts diff --git a/package.json b/package.json index 9dcc2fd..787e3a5 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "domains:sync": "node scripts/sync-global-domains.mjs", "i18n": "node scripts/i18n-validate.cjs", "i18n:validate": "node scripts/i18n-validate.cjs", + "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", diff --git a/scripts/web-crypto-availability.test.ts b/scripts/web-crypto-availability.test.ts new file mode 100644 index 0000000..27311f3 --- /dev/null +++ b/scripts/web-crypto-availability.test.ts @@ -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: (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)[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: (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); + } +}); diff --git a/webapp/src/lib/api/auth.ts b/webapp/src/lib/api/auth.ts index 8403ca0..ab6c1d4 100644 --- a/webapp/src/lib/api/auth.ts +++ b/webapp/src/lib/api/auth.ts @@ -1,4 +1,12 @@ -import { bytesToBase64, decryptBw, encryptBw, hkdfExpand, pbkdf2 } from '../crypto'; +import { + bytesToBase64, + decryptBw, + encryptBw, + hkdfExpand, + pbkdf2, + requireWebCrypto, + WebCryptoUnavailableError, +} from '../crypto'; import { t, translateServerError } from '../i18n'; import type { AuthorizedDevice } from '../types'; import type { @@ -428,14 +436,15 @@ export async function registerAccount(args: { }): Promise<{ ok: true } | { ok: false; message: string }> { try { const { email, name, password, masterPasswordHint, inviteCode, fallbackIterations } = args; + const webCrypto = requireWebCrypto(); const masterKey = await pbkdf2(password, email, fallbackIterations, 32); const masterHash = await pbkdf2(masterKey, password, 1, 32); const encKey = await hkdfExpand(masterKey, 'enc', 32); const macKey = await hkdfExpand(masterKey, 'mac', 32); - const sym = crypto.getRandomValues(new Uint8Array(64)); + const sym = webCrypto.getRandomValues(new Uint8Array(64)); const encryptedVaultKey = await encryptBw(sym, encKey, macKey); - const keyPair = await crypto.subtle.generateKey( + const keyPair = await webCrypto.subtle.generateKey( { name: 'RSA-OAEP', modulusLength: 2048, @@ -445,8 +454,8 @@ export async function registerAccount(args: { true, ['encrypt', 'decrypt'] ); - const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey)); - const privateKey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey)); + const publicKey = new Uint8Array(await webCrypto.subtle.exportKey('spki', keyPair.publicKey)); + const privateKey = new Uint8Array(await webCrypto.subtle.exportKey('pkcs8', keyPair.privateKey)); const encryptedPrivateKey = await encryptBw(privateKey, sym.slice(0, 32), sym.slice(32, 64)); const resp = await fetch('/api/accounts/register', { @@ -474,6 +483,9 @@ export async function registerAccount(args: { } return { ok: true }; } catch (error) { + if (error instanceof WebCryptoUnavailableError) { + return { ok: false, message: t('txt_web_crypto_unavailable') }; + } return { ok: false, message: error instanceof Error ? translateServerError(error.message, error.message) : t('txt_register_failed') }; } } diff --git a/webapp/src/lib/crypto.ts b/webapp/src/lib/crypto.ts index 0e8ffa3..61ea381 100644 --- a/webapp/src/lib/crypto.ts +++ b/webapp/src/lib/crypto.ts @@ -1,3 +1,34 @@ +export const WEB_CRYPTO_UNAVAILABLE_MESSAGE = + 'Secure browser cryptography is unavailable. Open NodeWarden over HTTPS in a supported browser.'; + +export class WebCryptoUnavailableError extends Error { + constructor() { + super(WEB_CRYPTO_UNAVAILABLE_MESSAGE); + this.name = 'WebCryptoUnavailableError'; + } +} + +interface WebCryptoEnvironment { + crypto?: Crypto; + isSecureContext?: boolean; +} + +export function requireWebCrypto( + environment: WebCryptoEnvironment = globalThis as unknown as WebCryptoEnvironment +): Crypto { + const cryptoApi = environment.crypto; + if ( + environment.isSecureContext === false || + !cryptoApi || + typeof cryptoApi.getRandomValues !== 'function' || + !cryptoApi.subtle || + typeof cryptoApi.subtle.importKey !== 'function' + ) { + throw new WebCryptoUnavailableError(); + } + return cryptoApi; +} + export function bytesToBase64(bytes: Uint8Array): string { let s = ''; for (let i = 0; i < bytes.length; i += 1) s += String.fromCharCode(bytes[i]); @@ -24,7 +55,7 @@ export function toBufferSource(bytes: Uint8Array): ArrayBuffer { export async function sha256Base64(value: string): Promise { const bytes = new TextEncoder().encode(value); - const hash = await crypto.subtle.digest('SHA-256', toBufferSource(bytes)); + const hash = await requireWebCrypto().subtle.digest('SHA-256', toBufferSource(bytes)); return bytesToBase64(new Uint8Array(hash)); } @@ -51,7 +82,7 @@ function getHmacSha256Key(keyBytes: Uint8Array): Promise { return getCachedCryptoKey( hmacSha256KeyCache, keyBytes, - () => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) + () => requireWebCrypto().subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) ); } @@ -59,7 +90,7 @@ function getAesCbcEncryptKey(keyBytes: Uint8Array): Promise { return getCachedCryptoKey( aesCbcEncryptKeyCache, keyBytes, - () => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['encrypt']) + () => requireWebCrypto().subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['encrypt']) ); } @@ -67,7 +98,7 @@ function getAesCbcDecryptKey(keyBytes: Uint8Array): Promise { return getCachedCryptoKey( aesCbcDecryptKeyCache, keyBytes, - () => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['decrypt']) + () => requireWebCrypto().subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['decrypt']) ); } @@ -88,8 +119,9 @@ export async function pbkdf2( ): Promise { const pwdBytes = typeof passwordOrBytes === 'string' ? new TextEncoder().encode(passwordOrBytes) : passwordOrBytes; const saltBytes = typeof saltOrBytes === 'string' ? new TextEncoder().encode(saltOrBytes) : saltOrBytes; - const key = await crypto.subtle.importKey('raw', toBufferSource(pwdBytes), 'PBKDF2', false, ['deriveBits']); - const bits = await crypto.subtle.deriveBits( + const subtle = requireWebCrypto().subtle; + const key = await subtle.importKey('raw', toBufferSource(pwdBytes), 'PBKDF2', false, ['deriveBits']); + const bits = await subtle.deriveBits( { name: 'PBKDF2', hash: 'SHA-256', salt: toBufferSource(saltBytes), iterations }, key, keyLen * 8 @@ -99,7 +131,8 @@ export async function pbkdf2( export async function hkdfExpand(prk: Uint8Array, info: string, length: number): Promise { const infoBytes = new TextEncoder().encode(info || ''); - const key = await crypto.subtle.importKey('raw', toBufferSource(prk), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); + const subtle = requireWebCrypto().subtle; + const key = await subtle.importKey('raw', toBufferSource(prk), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); const result = new Uint8Array(length); let previous = new Uint8Array(0); let offset = 0; @@ -110,7 +143,7 @@ export async function hkdfExpand(prk: Uint8Array, info: string, length: number): input.set(previous, 0); input.set(infoBytes, previous.length); input[input.length - 1] = counter & 0xff; - previous = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(input))); + previous = new Uint8Array(await subtle.sign('HMAC', key, toBufferSource(input))); const copyLen = Math.min(previous.length, length - offset); result.set(previous.slice(0, copyLen), offset); offset += copyLen; @@ -134,28 +167,29 @@ export async function hkdf( info: toBufferSource(infoBytes), hash: 'SHA-256', }; - const key = await crypto.subtle.importKey('raw', toBufferSource(ikm), 'HKDF', false, ['deriveBits']); - const bits = await crypto.subtle.deriveBits(params, key, outputByteSize * 8); + const subtle = requireWebCrypto().subtle; + const key = await subtle.importKey('raw', toBufferSource(ikm), 'HKDF', false, ['deriveBits']); + const bits = await subtle.deriveBits(params, key, outputByteSize * 8); return new Uint8Array(bits); } async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise { 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 { 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 { 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 { - const iv = crypto.getRandomValues(new Uint8Array(16)); + const iv = requireWebCrypto().getRandomValues(new Uint8Array(16)); const cipher = await encryptAesCbc(data, encKey, iv); const mac = await hmacSha256(macKey, concatBytes(iv, cipher)); const out = new Uint8Array(1 + iv.length + mac.length + cipher.length); @@ -179,7 +213,7 @@ export async function decryptBwFileData(encrypted: Uint8Array, encKey: Uint8Arra } export async function encryptBw(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise { - const iv = crypto.getRandomValues(new Uint8Array(16)); + const iv = requireWebCrypto().getRandomValues(new Uint8Array(16)); const cipher = await encryptAesCbc(data, encKey, iv); const mac = await hmacSha256(macKey, concatBytes(iv, cipher)); return `2.${bytesToBase64(iv)}|${bytesToBase64(cipher)}|${bytesToBase64(mac)}`; @@ -556,8 +590,9 @@ export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now()) message[i] = c & 0xff; c = Math.floor(c / 256); } - const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: algorithm }, false, ['sign']); - const hs = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(message))); + const subtle = requireWebCrypto().subtle; + const key = await subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: algorithm }, false, ['sign']); + const hs = new Uint8Array(await subtle.sign('HMAC', key, toBufferSource(message))); const offset = hs[hs.length - 1] & 0x0f; const bin = ((hs[offset] & 0x7f) << 24) | ((hs[offset + 1] & 0xff) << 16) | ((hs[offset + 2] & 0xff) << 8) | (hs[offset + 3] & 0xff); let code = (bin % (10 ** digits)).toString().padStart(digits, '0'); diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index cd78531..d5739dd 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -238,6 +238,7 @@ export function translateServerError(message: string | null | undefined, fallbac 'WebDAV server URL is required': 'txt_backup_error_webdav_url_required', 'WebDAV server URL must start with http:// or https://': 'txt_backup_error_webdav_url_protocol', 'WebDAV username is required': 'txt_backup_error_webdav_username_required', + 'Secure browser cryptography is unavailable. Open NodeWarden over HTTPS in a supported browser.': 'txt_web_crypto_unavailable', 'masterPasswordHash is required': 'txt_server_error_master_password_hash_required', 'masterPasswordHash or userVerificationToken is required': 'txt_server_error_master_password_or_verification_required', }[normalized]; diff --git a/webapp/src/lib/i18n/locales/de.ts b/webapp/src/lib/i18n/locales/de.ts index 36d5b70..fa268ec 100644 --- a/webapp/src/lib/i18n/locales/de.ts +++ b/webapp/src/lib/i18n/locales/de.ts @@ -511,6 +511,7 @@ const de: Record = { "txt_create_account": "Konto erstellen", "txt_registering": "Konto wird erstellt...", "txt_register_failed": "Registrierung fehlgeschlagen", + "txt_web_crypto_unavailable": "Sichere Browser-Kryptografie ist nicht verfügbar. Öffnen Sie NodeWarden über HTTPS in einem unterstützten Browser.", "txt_create_folder": "Ordner erstellen", "txt_create_folder_failed": "Fehler beim Erstellen des Ordners", "txt_create_item_failed": "Fehler beim Erstellen des Eintrags", diff --git a/webapp/src/lib/i18n/locales/en.ts b/webapp/src/lib/i18n/locales/en.ts index eaa5613..4e413c7 100644 --- a/webapp/src/lib/i18n/locales/en.ts +++ b/webapp/src/lib/i18n/locales/en.ts @@ -534,6 +534,7 @@ const en: Record = { "txt_create_account": "Create Account", "txt_registering": "Creating account...", "txt_register_failed": "Register failed", + "txt_web_crypto_unavailable": "Secure browser cryptography is unavailable. Open NodeWarden over HTTPS in a supported browser.", "txt_create_folder": "Create Folder", "txt_create_folder_failed": "Create folder failed", "txt_create_item_failed": "Create item failed", diff --git a/webapp/src/lib/i18n/locales/es.ts b/webapp/src/lib/i18n/locales/es.ts index 19270d2..4bc818c 100644 --- a/webapp/src/lib/i18n/locales/es.ts +++ b/webapp/src/lib/i18n/locales/es.ts @@ -511,6 +511,7 @@ const es: Record = { "txt_create_account": "Crear cuenta", "txt_registering": "Creando cuenta...", "txt_register_failed": "Error al registrarse", + "txt_web_crypto_unavailable": "La criptografía segura del navegador no está disponible. Abra NodeWarden mediante HTTPS en un navegador compatible.", "txt_create_folder": "Crear carpeta", "txt_create_folder_failed": "Error al crear carpeta", "txt_create_item_failed": "Error al crear elemento", diff --git a/webapp/src/lib/i18n/locales/fi.ts b/webapp/src/lib/i18n/locales/fi.ts index 65a4a5e..7cf31af 100644 --- a/webapp/src/lib/i18n/locales/fi.ts +++ b/webapp/src/lib/i18n/locales/fi.ts @@ -511,6 +511,7 @@ const fi: Record = { "txt_create_account": "Luo tili", "txt_registering": "Luodaan tiliä...", "txt_register_failed": "Rekisteröinti epäonnistui", + "txt_web_crypto_unavailable": "Selaimen suojattu salaus ei ole käytettävissä. Avaa NodeWarden HTTPS-yhteydellä tuetussa selaimessa.", "txt_create_folder": "Luo kansio", "txt_create_folder_failed": "Kansion luonti epäonnistui", "txt_create_item_failed": "Nimikkeen luonti epäonnistui", diff --git a/webapp/src/lib/i18n/locales/fr.ts b/webapp/src/lib/i18n/locales/fr.ts index aa603d1..d3586dc 100644 --- a/webapp/src/lib/i18n/locales/fr.ts +++ b/webapp/src/lib/i18n/locales/fr.ts @@ -511,6 +511,7 @@ const fr: Record = { "txt_create_account": "Créer un compte", "txt_registering": "Création du compte...", "txt_register_failed": "L'inscription a échoué", + "txt_web_crypto_unavailable": "La cryptographie sécurisée du navigateur n’est pas disponible. Ouvrez NodeWarden via HTTPS dans un navigateur compatible.", "txt_create_folder": "Créer un dossier", "txt_create_folder_failed": "La création du dossier a échoué", "txt_create_item_failed": "La création de l'élément a échoué", diff --git a/webapp/src/lib/i18n/locales/it.ts b/webapp/src/lib/i18n/locales/it.ts index 72f3e5d..cdfab57 100644 --- a/webapp/src/lib/i18n/locales/it.ts +++ b/webapp/src/lib/i18n/locales/it.ts @@ -511,6 +511,7 @@ const it: Record = { "txt_create_account": "Crea Account", "txt_registering": "Creazione Account in corso...", "txt_register_failed": "Registrazione fallita", + "txt_web_crypto_unavailable": "La crittografia sicura del browser non è disponibile. Apri NodeWarden tramite HTTPS in un browser supportato.", "txt_create_folder": "Crea cartella", "txt_create_folder_failed": "Impossibile creare la cartella", "txt_create_item_failed": "Impossibile creare l'elemento", diff --git a/webapp/src/lib/i18n/locales/ru.ts b/webapp/src/lib/i18n/locales/ru.ts index b481fa3..8f0fd4a 100644 --- a/webapp/src/lib/i18n/locales/ru.ts +++ b/webapp/src/lib/i18n/locales/ru.ts @@ -511,6 +511,7 @@ const ru: Record = { "txt_create_account": "Создать учетную запись", "txt_registering": "Создание учетной записи...", "txt_register_failed": "Не удалось зарегистрироваться", + "txt_web_crypto_unavailable": "Безопасная криптография браузера недоступна. Откройте NodeWarden по HTTPS в поддерживаемом браузере.", "txt_create_folder": "Создать папку", "txt_create_folder_failed": "Создать папку не удалось", "txt_create_item_failed": "Создать элемент не удалось", diff --git a/webapp/src/lib/i18n/locales/sv.ts b/webapp/src/lib/i18n/locales/sv.ts index 13177fd..8b54146 100644 --- a/webapp/src/lib/i18n/locales/sv.ts +++ b/webapp/src/lib/i18n/locales/sv.ts @@ -511,6 +511,7 @@ const sv: Record = { "txt_create_account": "Skapa konto", "txt_registering": "Skapar konto...", "txt_register_failed": "Registrering misslyckades", + "txt_web_crypto_unavailable": "Säker webbläsarkryptografi är inte tillgänglig. Öppna NodeWarden via HTTPS i en webbläsare som stöds.", "txt_create_folder": "Skapa mapp", "txt_create_folder_failed": "Misslyckades med att skapa mapp", "txt_create_item_failed": "Misslyckades med att skapa objekt", diff --git a/webapp/src/lib/i18n/locales/zh-CN.ts b/webapp/src/lib/i18n/locales/zh-CN.ts index 8785ad6..918f76a 100644 --- a/webapp/src/lib/i18n/locales/zh-CN.ts +++ b/webapp/src/lib/i18n/locales/zh-CN.ts @@ -514,6 +514,7 @@ const zhCN: Record = { "txt_create_account": "创建账户", "txt_registering": "正在注册...", "txt_register_failed": "注册失败", + "txt_web_crypto_unavailable": "当前浏览器环境无法使用安全加密。请通过 HTTPS 打开 NodeWarden,并使用受支持的现代浏览器。", "txt_create_folder": "创建文件夹", "txt_create_folder_failed": "创建文件夹失败", "txt_create_item_failed": "创建项目失败", diff --git a/webapp/src/lib/i18n/locales/zh-TW.ts b/webapp/src/lib/i18n/locales/zh-TW.ts index 48357f1..21efc82 100644 --- a/webapp/src/lib/i18n/locales/zh-TW.ts +++ b/webapp/src/lib/i18n/locales/zh-TW.ts @@ -514,6 +514,7 @@ const zhTW: Record = { "txt_create_account": "創建賬戶", "txt_registering": "正在註冊...", "txt_register_failed": "註冊失敗", + "txt_web_crypto_unavailable": "目前瀏覽器環境無法使用安全加密。請透過 HTTPS 開啟 NodeWarden,並使用受支援的現代瀏覽器。", "txt_create_folder": "創建文件夾", "txt_create_folder_failed": "創建文件夾失敗", "txt_create_item_failed": "創建項目失敗",