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