mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 06:50:10 +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:
@@ -15,6 +15,7 @@
|
|||||||
"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:web-crypto": "tsx --test scripts/web-crypto-availability.test.ts",
|
||||||
"test:webauthn-mobile": "node --test scripts/webauthn-mobile-connector.test.mjs",
|
"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-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",
|
"test:webauthn-connectors": "node --test scripts/webauthn-mobile-connector.test.mjs scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.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: <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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
import { bytesToBase64, decryptBw, encryptBw, hkdfExpand, pbkdf2 } from '../crypto';
|
import {
|
||||||
|
bytesToBase64,
|
||||||
|
decryptBw,
|
||||||
|
encryptBw,
|
||||||
|
hkdfExpand,
|
||||||
|
pbkdf2,
|
||||||
|
requireWebCrypto,
|
||||||
|
WebCryptoUnavailableError,
|
||||||
|
} from '../crypto';
|
||||||
import { t, translateServerError } from '../i18n';
|
import { t, translateServerError } from '../i18n';
|
||||||
import type { AuthorizedDevice } from '../types';
|
import type { AuthorizedDevice } from '../types';
|
||||||
import type {
|
import type {
|
||||||
@@ -428,14 +436,15 @@ export async function registerAccount(args: {
|
|||||||
}): Promise<{ ok: true } | { ok: false; message: string }> {
|
}): Promise<{ ok: true } | { ok: false; message: string }> {
|
||||||
try {
|
try {
|
||||||
const { email, name, password, masterPasswordHint, inviteCode, fallbackIterations } = args;
|
const { email, name, password, masterPasswordHint, inviteCode, fallbackIterations } = args;
|
||||||
|
const webCrypto = requireWebCrypto();
|
||||||
const masterKey = await pbkdf2(password, email, fallbackIterations, 32);
|
const masterKey = await pbkdf2(password, email, fallbackIterations, 32);
|
||||||
const masterHash = await pbkdf2(masterKey, password, 1, 32);
|
const masterHash = await pbkdf2(masterKey, password, 1, 32);
|
||||||
const encKey = await hkdfExpand(masterKey, 'enc', 32);
|
const encKey = await hkdfExpand(masterKey, 'enc', 32);
|
||||||
const macKey = await hkdfExpand(masterKey, 'mac', 32);
|
const macKey = await hkdfExpand(masterKey, 'mac', 32);
|
||||||
const sym = crypto.getRandomValues(new Uint8Array(64));
|
const sym = webCrypto.getRandomValues(new Uint8Array(64));
|
||||||
const encryptedVaultKey = await encryptBw(sym, encKey, macKey);
|
const encryptedVaultKey = await encryptBw(sym, encKey, macKey);
|
||||||
|
|
||||||
const keyPair = await crypto.subtle.generateKey(
|
const keyPair = await webCrypto.subtle.generateKey(
|
||||||
{
|
{
|
||||||
name: 'RSA-OAEP',
|
name: 'RSA-OAEP',
|
||||||
modulusLength: 2048,
|
modulusLength: 2048,
|
||||||
@@ -445,8 +454,8 @@ export async function registerAccount(args: {
|
|||||||
true,
|
true,
|
||||||
['encrypt', 'decrypt']
|
['encrypt', 'decrypt']
|
||||||
);
|
);
|
||||||
const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey));
|
const publicKey = new Uint8Array(await webCrypto.subtle.exportKey('spki', keyPair.publicKey));
|
||||||
const privateKey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey));
|
const privateKey = new Uint8Array(await webCrypto.subtle.exportKey('pkcs8', keyPair.privateKey));
|
||||||
const encryptedPrivateKey = await encryptBw(privateKey, sym.slice(0, 32), sym.slice(32, 64));
|
const encryptedPrivateKey = await encryptBw(privateKey, sym.slice(0, 32), sym.slice(32, 64));
|
||||||
|
|
||||||
const resp = await fetch('/api/accounts/register', {
|
const resp = await fetch('/api/accounts/register', {
|
||||||
@@ -474,6 +483,9 @@ export async function registerAccount(args: {
|
|||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof WebCryptoUnavailableError) {
|
||||||
|
return { ok: false, message: t('txt_web_crypto_unavailable') };
|
||||||
|
}
|
||||||
return { ok: false, message: error instanceof Error ? translateServerError(error.message, error.message) : t('txt_register_failed') };
|
return { ok: false, message: error instanceof Error ? translateServerError(error.message, error.message) : t('txt_register_failed') };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-17
@@ -1,3 +1,34 @@
|
|||||||
|
export const WEB_CRYPTO_UNAVAILABLE_MESSAGE =
|
||||||
|
'Secure browser cryptography is unavailable. Open NodeWarden over HTTPS in a supported browser.';
|
||||||
|
|
||||||
|
export class WebCryptoUnavailableError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super(WEB_CRYPTO_UNAVAILABLE_MESSAGE);
|
||||||
|
this.name = 'WebCryptoUnavailableError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebCryptoEnvironment {
|
||||||
|
crypto?: Crypto;
|
||||||
|
isSecureContext?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireWebCrypto(
|
||||||
|
environment: WebCryptoEnvironment = globalThis as unknown as WebCryptoEnvironment
|
||||||
|
): Crypto {
|
||||||
|
const cryptoApi = environment.crypto;
|
||||||
|
if (
|
||||||
|
environment.isSecureContext === false ||
|
||||||
|
!cryptoApi ||
|
||||||
|
typeof cryptoApi.getRandomValues !== 'function' ||
|
||||||
|
!cryptoApi.subtle ||
|
||||||
|
typeof cryptoApi.subtle.importKey !== 'function'
|
||||||
|
) {
|
||||||
|
throw new WebCryptoUnavailableError();
|
||||||
|
}
|
||||||
|
return cryptoApi;
|
||||||
|
}
|
||||||
|
|
||||||
export function bytesToBase64(bytes: Uint8Array): string {
|
export function bytesToBase64(bytes: Uint8Array): string {
|
||||||
let s = '';
|
let s = '';
|
||||||
for (let i = 0; i < bytes.length; i += 1) s += String.fromCharCode(bytes[i]);
|
for (let i = 0; i < bytes.length; i += 1) s += String.fromCharCode(bytes[i]);
|
||||||
@@ -24,7 +55,7 @@ export function toBufferSource(bytes: Uint8Array): ArrayBuffer {
|
|||||||
|
|
||||||
export async function sha256Base64(value: string): Promise<string> {
|
export async function sha256Base64(value: string): Promise<string> {
|
||||||
const bytes = new TextEncoder().encode(value);
|
const bytes = new TextEncoder().encode(value);
|
||||||
const hash = await crypto.subtle.digest('SHA-256', toBufferSource(bytes));
|
const hash = await requireWebCrypto().subtle.digest('SHA-256', toBufferSource(bytes));
|
||||||
return bytesToBase64(new Uint8Array(hash));
|
return bytesToBase64(new Uint8Array(hash));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +82,7 @@ function getHmacSha256Key(keyBytes: Uint8Array): Promise<CryptoKey> {
|
|||||||
return getCachedCryptoKey(
|
return getCachedCryptoKey(
|
||||||
hmacSha256KeyCache,
|
hmacSha256KeyCache,
|
||||||
keyBytes,
|
keyBytes,
|
||||||
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
|
() => requireWebCrypto().subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +90,7 @@ function getAesCbcEncryptKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
|||||||
return getCachedCryptoKey(
|
return getCachedCryptoKey(
|
||||||
aesCbcEncryptKeyCache,
|
aesCbcEncryptKeyCache,
|
||||||
keyBytes,
|
keyBytes,
|
||||||
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['encrypt'])
|
() => requireWebCrypto().subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['encrypt'])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +98,7 @@ function getAesCbcDecryptKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
|||||||
return getCachedCryptoKey(
|
return getCachedCryptoKey(
|
||||||
aesCbcDecryptKeyCache,
|
aesCbcDecryptKeyCache,
|
||||||
keyBytes,
|
keyBytes,
|
||||||
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['decrypt'])
|
() => requireWebCrypto().subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['decrypt'])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,8 +119,9 @@ export async function pbkdf2(
|
|||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const pwdBytes = typeof passwordOrBytes === 'string' ? new TextEncoder().encode(passwordOrBytes) : passwordOrBytes;
|
const pwdBytes = typeof passwordOrBytes === 'string' ? new TextEncoder().encode(passwordOrBytes) : passwordOrBytes;
|
||||||
const saltBytes = typeof saltOrBytes === 'string' ? new TextEncoder().encode(saltOrBytes) : saltOrBytes;
|
const saltBytes = typeof saltOrBytes === 'string' ? new TextEncoder().encode(saltOrBytes) : saltOrBytes;
|
||||||
const key = await crypto.subtle.importKey('raw', toBufferSource(pwdBytes), 'PBKDF2', false, ['deriveBits']);
|
const subtle = requireWebCrypto().subtle;
|
||||||
const bits = await crypto.subtle.deriveBits(
|
const key = await subtle.importKey('raw', toBufferSource(pwdBytes), 'PBKDF2', false, ['deriveBits']);
|
||||||
|
const bits = await subtle.deriveBits(
|
||||||
{ name: 'PBKDF2', hash: 'SHA-256', salt: toBufferSource(saltBytes), iterations },
|
{ name: 'PBKDF2', hash: 'SHA-256', salt: toBufferSource(saltBytes), iterations },
|
||||||
key,
|
key,
|
||||||
keyLen * 8
|
keyLen * 8
|
||||||
@@ -99,7 +131,8 @@ export async function pbkdf2(
|
|||||||
|
|
||||||
export async function hkdfExpand(prk: Uint8Array, info: string, length: number): Promise<Uint8Array> {
|
export async function hkdfExpand(prk: Uint8Array, info: string, length: number): Promise<Uint8Array> {
|
||||||
const infoBytes = new TextEncoder().encode(info || '');
|
const infoBytes = new TextEncoder().encode(info || '');
|
||||||
const key = await crypto.subtle.importKey('raw', toBufferSource(prk), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
const subtle = requireWebCrypto().subtle;
|
||||||
|
const key = await subtle.importKey('raw', toBufferSource(prk), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||||
const result = new Uint8Array(length);
|
const result = new Uint8Array(length);
|
||||||
let previous = new Uint8Array(0);
|
let previous = new Uint8Array(0);
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
@@ -110,7 +143,7 @@ export async function hkdfExpand(prk: Uint8Array, info: string, length: number):
|
|||||||
input.set(previous, 0);
|
input.set(previous, 0);
|
||||||
input.set(infoBytes, previous.length);
|
input.set(infoBytes, previous.length);
|
||||||
input[input.length - 1] = counter & 0xff;
|
input[input.length - 1] = counter & 0xff;
|
||||||
previous = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(input)));
|
previous = new Uint8Array(await subtle.sign('HMAC', key, toBufferSource(input)));
|
||||||
const copyLen = Math.min(previous.length, length - offset);
|
const copyLen = Math.min(previous.length, length - offset);
|
||||||
result.set(previous.slice(0, copyLen), offset);
|
result.set(previous.slice(0, copyLen), offset);
|
||||||
offset += copyLen;
|
offset += copyLen;
|
||||||
@@ -134,28 +167,29 @@ export async function hkdf(
|
|||||||
info: toBufferSource(infoBytes),
|
info: toBufferSource(infoBytes),
|
||||||
hash: 'SHA-256',
|
hash: 'SHA-256',
|
||||||
};
|
};
|
||||||
const key = await crypto.subtle.importKey('raw', toBufferSource(ikm), 'HKDF', false, ['deriveBits']);
|
const subtle = requireWebCrypto().subtle;
|
||||||
const bits = await crypto.subtle.deriveBits(params, key, outputByteSize * 8);
|
const key = await subtle.importKey('raw', toBufferSource(ikm), 'HKDF', false, ['deriveBits']);
|
||||||
|
const bits = await subtle.deriveBits(params, key, outputByteSize * 8);
|
||||||
return new Uint8Array(bits);
|
return new Uint8Array(bits);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise<Uint8Array> {
|
async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise<Uint8Array> {
|
||||||
const key = await getHmacSha256Key(keyBytes);
|
const key = await getHmacSha256Key(keyBytes);
|
||||||
return new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(dataBytes)));
|
return new Uint8Array(await requireWebCrypto().subtle.sign('HMAC', key, toBufferSource(dataBytes)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function encryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
|
async function encryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
|
||||||
const cryptoKey = await getAesCbcEncryptKey(key);
|
const cryptoKey = await getAesCbcEncryptKey(key);
|
||||||
return new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
|
return new Uint8Array(await requireWebCrypto().subtle.encrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function decryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
|
async function decryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
|
||||||
const cryptoKey = await getAesCbcDecryptKey(key);
|
const cryptoKey = await getAesCbcDecryptKey(key);
|
||||||
return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
|
return new Uint8Array(await requireWebCrypto().subtle.decrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function encryptBwFileData(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<Uint8Array> {
|
export async function encryptBwFileData(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<Uint8Array> {
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(16));
|
const iv = requireWebCrypto().getRandomValues(new Uint8Array(16));
|
||||||
const cipher = await encryptAesCbc(data, encKey, iv);
|
const cipher = await encryptAesCbc(data, encKey, iv);
|
||||||
const mac = await hmacSha256(macKey, concatBytes(iv, cipher));
|
const mac = await hmacSha256(macKey, concatBytes(iv, cipher));
|
||||||
const out = new Uint8Array(1 + iv.length + mac.length + cipher.length);
|
const out = new Uint8Array(1 + iv.length + mac.length + cipher.length);
|
||||||
@@ -179,7 +213,7 @@ export async function decryptBwFileData(encrypted: Uint8Array, encKey: Uint8Arra
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function encryptBw(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<string> {
|
export async function encryptBw(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<string> {
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(16));
|
const iv = requireWebCrypto().getRandomValues(new Uint8Array(16));
|
||||||
const cipher = await encryptAesCbc(data, encKey, iv);
|
const cipher = await encryptAesCbc(data, encKey, iv);
|
||||||
const mac = await hmacSha256(macKey, concatBytes(iv, cipher));
|
const mac = await hmacSha256(macKey, concatBytes(iv, cipher));
|
||||||
return `2.${bytesToBase64(iv)}|${bytesToBase64(cipher)}|${bytesToBase64(mac)}`;
|
return `2.${bytesToBase64(iv)}|${bytesToBase64(cipher)}|${bytesToBase64(mac)}`;
|
||||||
@@ -556,8 +590,9 @@ export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now())
|
|||||||
message[i] = c & 0xff;
|
message[i] = c & 0xff;
|
||||||
c = Math.floor(c / 256);
|
c = Math.floor(c / 256);
|
||||||
}
|
}
|
||||||
const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: algorithm }, false, ['sign']);
|
const subtle = requireWebCrypto().subtle;
|
||||||
const hs = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(message)));
|
const key = await subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: algorithm }, false, ['sign']);
|
||||||
|
const hs = new Uint8Array(await subtle.sign('HMAC', key, toBufferSource(message)));
|
||||||
const offset = hs[hs.length - 1] & 0x0f;
|
const offset = hs[hs.length - 1] & 0x0f;
|
||||||
const bin = ((hs[offset] & 0x7f) << 24) | ((hs[offset + 1] & 0xff) << 16) | ((hs[offset + 2] & 0xff) << 8) | (hs[offset + 3] & 0xff);
|
const bin = ((hs[offset] & 0x7f) << 24) | ((hs[offset + 1] & 0xff) << 16) | ((hs[offset + 2] & 0xff) << 8) | (hs[offset + 3] & 0xff);
|
||||||
let code = (bin % (10 ** digits)).toString().padStart(digits, '0');
|
let code = (bin % (10 ** digits)).toString().padStart(digits, '0');
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ export function translateServerError(message: string | null | undefined, fallbac
|
|||||||
'WebDAV server URL is required': 'txt_backup_error_webdav_url_required',
|
'WebDAV server URL is required': 'txt_backup_error_webdav_url_required',
|
||||||
'WebDAV server URL must start with http:// or https://': 'txt_backup_error_webdav_url_protocol',
|
'WebDAV server URL must start with http:// or https://': 'txt_backup_error_webdav_url_protocol',
|
||||||
'WebDAV username is required': 'txt_backup_error_webdav_username_required',
|
'WebDAV username is required': 'txt_backup_error_webdav_username_required',
|
||||||
|
'Secure browser cryptography is unavailable. Open NodeWarden over HTTPS in a supported browser.': 'txt_web_crypto_unavailable',
|
||||||
'masterPasswordHash is required': 'txt_server_error_master_password_hash_required',
|
'masterPasswordHash is required': 'txt_server_error_master_password_hash_required',
|
||||||
'masterPasswordHash or userVerificationToken is required': 'txt_server_error_master_password_or_verification_required',
|
'masterPasswordHash or userVerificationToken is required': 'txt_server_error_master_password_or_verification_required',
|
||||||
}[normalized];
|
}[normalized];
|
||||||
|
|||||||
@@ -511,6 +511,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",
|
||||||
|
|||||||
@@ -534,6 +534,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",
|
||||||
|
|||||||
@@ -511,6 +511,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",
|
||||||
|
|||||||
@@ -511,6 +511,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",
|
||||||
|
|||||||
@@ -511,6 +511,7 @@ const fr: Record<string, string> = {
|
|||||||
"txt_create_account": "Créer un compte",
|
"txt_create_account": "Créer un compte",
|
||||||
"txt_registering": "Création du compte...",
|
"txt_registering": "Création du compte...",
|
||||||
"txt_register_failed": "L'inscription a échoué",
|
"txt_register_failed": "L'inscription a échoué",
|
||||||
|
"txt_web_crypto_unavailable": "La cryptographie sécurisée du navigateur n’est pas disponible. Ouvrez NodeWarden via HTTPS dans un navigateur compatible.",
|
||||||
"txt_create_folder": "Créer un dossier",
|
"txt_create_folder": "Créer un dossier",
|
||||||
"txt_create_folder_failed": "La création du dossier a échoué",
|
"txt_create_folder_failed": "La création du dossier a échoué",
|
||||||
"txt_create_item_failed": "La création de l'élément a échoué",
|
"txt_create_item_failed": "La création de l'élément a échoué",
|
||||||
|
|||||||
@@ -511,6 +511,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",
|
||||||
|
|||||||
@@ -511,6 +511,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": "Создать элемент не удалось",
|
||||||
|
|||||||
@@ -511,6 +511,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",
|
||||||
|
|||||||
@@ -514,6 +514,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": "创建项目失败",
|
||||||
|
|||||||
@@ -514,6 +514,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": "創建項目失敗",
|
||||||
|
|||||||
Reference in New Issue
Block a user