feat: implement account passkey functionality

- Added functions for managing account passkeys including creation, listing, updating, and deletion.
- Introduced login methods using account passkeys with options for direct unlock and login-only modes.
- Enhanced error handling and response parsing for passkey-related API calls.
- Updated UI styles for account passkey management components.
- Added new translations for account passkey features in multiple languages.
- Modified network status handling to improve service reachability checks.
This commit is contained in:
shuaiplus
2026-06-10 00:53:41 +08:00
parent 615caf5946
commit 18d3490c4f
38 changed files with 3907 additions and 174 deletions
+308
View File
@@ -0,0 +1,308 @@
import { base64ToBytes, bytesToBase64, decryptBw, encryptBw, hkdfExpand, toBufferSource } from './crypto';
import type { AccountPasskeyPrfOption } from './types';
const LOGIN_WITH_PRF_SALT = 'passwordless-login';
export interface AccountPasskeyAssertion {
token: string;
deviceResponse: Record<string, unknown>;
prfKey?: Uint8Array;
}
export interface PendingAccountPasskeyCredential {
token: string;
createOptions: PublicKeyCredentialCreationOptions;
deviceResponse: PublicKeyCredential;
request: Record<string, unknown>;
supportsPrf: boolean;
}
export interface AccountPasskeyPrfKeySet {
encryptedUserKey: string;
encryptedPublicKey: string;
encryptedPrivateKey: string;
}
function bytesToBase64Url(bytes: Uint8Array): string {
return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function base64UrlToBytes(value: string): Uint8Array {
const normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized + '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
return base64ToBytes(padded);
}
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return toBufferSource(bytes);
}
function cloneCreationOptions(options: any): PublicKeyCredentialCreationOptions {
if (!options || typeof options !== 'object') throw new Error('Invalid passkey creation options');
return {
...options,
challenge: toArrayBuffer(base64UrlToBytes(options.challenge)),
user: {
...options.user,
id: toArrayBuffer(base64UrlToBytes(options.user?.id)),
},
excludeCredentials: Array.isArray(options.excludeCredentials)
? options.excludeCredentials.map((credential: any) => ({
...credential,
id: toArrayBuffer(base64UrlToBytes(credential.id)),
}))
: undefined,
};
}
function cloneRequestOptions(options: any): PublicKeyCredentialRequestOptions {
if (!options || typeof options !== 'object') throw new Error('Invalid passkey assertion options');
return {
...options,
challenge: toArrayBuffer(base64UrlToBytes(options.challenge)),
allowCredentials: Array.isArray(options.allowCredentials)
? options.allowCredentials.map((credential: any) => ({
...credential,
id: toArrayBuffer(base64UrlToBytes(credential.id)),
}))
: options.allowCredentials,
};
}
async function getLoginWithPrfSalt(): Promise<Uint8Array> {
const hash = await crypto.subtle.digest('SHA-256', toBufferSource(new TextEncoder().encode(LOGIN_WITH_PRF_SALT)));
return new Uint8Array(hash);
}
function credentialIdToBase64Url(id: BufferSource): string | null {
try {
const bytes = id instanceof ArrayBuffer
? new Uint8Array(id)
: new Uint8Array(id.buffer, id.byteOffset, id.byteLength);
return bytesToBase64Url(bytes);
} catch {
return null;
}
}
function buildPrfExtension(
salt: Uint8Array,
credentialIds: Array<string | null | undefined> = []
): Record<string, unknown> {
const evalInput = { first: salt };
const evalByCredential = credentialIds
.filter((id): id is string => !!id)
.reduce<Record<string, typeof evalInput>>((out, id) => {
out[id] = evalInput;
return out;
}, {});
return {
prf: {
eval: evalInput,
...(Object.keys(evalByCredential).length ? { evalByCredential } : {}),
},
};
}
function prfCredentialIdsFromAllowCredentials(options: PublicKeyCredentialRequestOptions): string[] {
return (options.allowCredentials || [])
.map((credential) => credentialIdToBase64Url(credential.id))
.filter((id): id is string => !!id);
}
async function prfOutputToKey(prfOutput: ArrayBuffer): Promise<Uint8Array> {
const prf = new Uint8Array(prfOutput);
const enc = await hkdfExpand(prf, 'enc', 32);
const mac = await hkdfExpand(prf, 'mac', 32);
const out = new Uint8Array(64);
out.set(enc, 0);
out.set(mac, 32);
return out;
}
function publicKeyCredentialBase(credential: PublicKeyCredential): Record<string, unknown> {
return {
id: credential.id,
rawId: bytesToBase64Url(new Uint8Array(credential.rawId)),
type: credential.type,
extensions: {},
};
}
function assertionRequest(credential: PublicKeyCredential): Record<string, unknown> {
if (!(credential.response instanceof AuthenticatorAssertionResponse)) {
throw new Error('Invalid passkey assertion response');
}
return {
...publicKeyCredentialBase(credential),
response: {
authenticatorData: bytesToBase64Url(new Uint8Array(credential.response.authenticatorData)),
signature: bytesToBase64Url(new Uint8Array(credential.response.signature)),
clientDataJSON: bytesToBase64Url(new Uint8Array(credential.response.clientDataJSON)),
userHandle: credential.response.userHandle
? bytesToBase64Url(new Uint8Array(credential.response.userHandle))
: undefined,
},
};
}
function attestationRequest(credential: PublicKeyCredential): Record<string, unknown> {
if (!(credential.response instanceof AuthenticatorAttestationResponse)) {
throw new Error('Invalid passkey registration response');
}
const transports = typeof credential.response.getTransports === 'function'
? credential.response.getTransports()
: undefined;
return {
...publicKeyCredentialBase(credential),
response: {
attestationObject: bytesToBase64Url(new Uint8Array(credential.response.attestationObject)),
clientDataJson: bytesToBase64Url(new Uint8Array(credential.response.clientDataJSON)),
transports,
},
};
}
export async function assertAccountPasskey(
response: { options: unknown; token: string }
): Promise<AccountPasskeyAssertion> {
if (!window.PublicKeyCredential || !navigator.credentials) {
throw new Error('Passkey is not supported in this browser');
}
const nativeOptions = cloneRequestOptions(response.options);
(nativeOptions as any).extensions = {
...((nativeOptions as any).extensions || {}),
...buildPrfExtension(await getLoginWithPrfSalt(), prfCredentialIdsFromAllowCredentials(nativeOptions)),
};
const credential = await navigator.credentials.get({ publicKey: nativeOptions });
if (!(credential instanceof PublicKeyCredential)) {
throw new Error('No passkey was selected');
}
const prfResult = (credential.getClientExtensionResults() as any).prf?.results?.first;
return {
token: response.token,
deviceResponse: assertionRequest(credential),
prfKey: prfResult ? await prfOutputToKey(prfResult) : undefined,
};
}
export async function createAccountPasskeyCredential(
response: { options: unknown; token: string }
): Promise<PendingAccountPasskeyCredential> {
if (!window.PublicKeyCredential || !navigator.credentials) {
throw new Error('Passkey is not supported in this browser');
}
const nativeOptions = cloneCreationOptions(response.options);
(nativeOptions as any).extensions = {
...((nativeOptions as any).extensions || {}),
prf: {},
};
const credential = await navigator.credentials.create({ publicKey: nativeOptions });
if (!(credential instanceof PublicKeyCredential)) {
throw new Error('No passkey was created');
}
const supportsPrf = !!(credential.getClientExtensionResults() as any).prf?.enabled;
return {
token: response.token,
createOptions: nativeOptions,
deviceResponse: credential,
request: attestationRequest(credential),
supportsPrf,
};
}
function parseRsaEncryptedUserKey(value: string): Uint8Array {
const text = String(value || '').trim();
const [type, payload] = text.split('.');
if (type !== '4' || !payload) throw new Error('Unsupported encrypted user key');
return base64ToBytes(payload);
}
export async function buildAccountPasskeyPrfKeySet(
pending: PendingAccountPasskeyCredential,
userKey: { symEncKey: string; symMacKey: string }
): Promise<AccountPasskeyPrfKeySet> {
const rawId = new Uint8Array(pending.deviceResponse.rawId);
const credentialId = bytesToBase64Url(rawId);
const assertionOptions: PublicKeyCredentialRequestOptions = {
challenge: pending.createOptions?.challenge!,
rpId: pending.createOptions?.rp?.id,
allowCredentials: [{ id: toArrayBuffer(rawId), type: 'public-key' }],
timeout: pending.createOptions?.timeout,
userVerification: pending.createOptions?.authenticatorSelection?.userVerification,
};
(assertionOptions as any).extensions = {
...buildPrfExtension(await getLoginWithPrfSalt(), [credentialId]),
};
const assertion = await navigator.credentials.get({ publicKey: assertionOptions });
if (!(assertion instanceof PublicKeyCredential)) {
throw new Error('Passkey verification failed');
}
const prfResult = (assertion.getClientExtensionResults() as any).prf?.results?.first;
if (!prfResult) {
throw new Error('This passkey does not support direct vault unlock');
}
return buildAccountPasskeyPrfKeySetFromPrfKey(await prfOutputToKey(prfResult), userKey);
}
export async function buildAccountPasskeyPrfKeySetFromPrfKey(
prfKey: Uint8Array,
userKey: { symEncKey: string; symMacKey: string }
): Promise<AccountPasskeyPrfKeySet> {
const userKeyBytes = new Uint8Array(64);
userKeyBytes.set(base64ToBytes(userKey.symEncKey), 0);
userKeyBytes.set(base64ToBytes(userKey.symMacKey), 32);
const pair = await crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-1',
},
true,
['encrypt', 'decrypt']
);
const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', pair.publicKey));
const privateKey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', pair.privateKey));
const encryptedUserKeyBytes = new Uint8Array(await crypto.subtle.encrypt(
{ name: 'RSA-OAEP' },
pair.publicKey,
toBufferSource(userKeyBytes)
));
return {
encryptedUserKey: `4.${bytesToBase64(encryptedUserKeyBytes)}`,
encryptedPublicKey: await encryptBw(publicKey, userKeyBytes.slice(0, 32), userKeyBytes.slice(32, 64)),
encryptedPrivateKey: await encryptBw(privateKey, prfKey.slice(0, 32), prfKey.slice(32, 64)),
};
}
export async function unlockVaultKeyWithAccountPasskeyPrf(
prfKey: Uint8Array,
option: AccountPasskeyPrfOption
): Promise<{ symEncKey: string; symMacKey: string }> {
const encryptedPrivateKey = option.EncryptedPrivateKey || option.encryptedPrivateKey || '';
const encryptedUserKey = option.EncryptedUserKey || option.encryptedUserKey || '';
if (!encryptedPrivateKey || !encryptedUserKey) {
throw new Error('Passkey cannot unlock this vault');
}
const privateKeyBytes = await decryptBw(encryptedPrivateKey, prfKey.slice(0, 32), prfKey.slice(32, 64));
const privateKey = await crypto.subtle.importKey(
'pkcs8',
toBufferSource(privateKeyBytes),
{ name: 'RSA-OAEP', hash: 'SHA-1' },
false,
['decrypt']
);
const userKeyBytes = new Uint8Array(await crypto.subtle.decrypt(
{ name: 'RSA-OAEP' },
privateKey,
toBufferSource(parseRsaEncryptedUserKey(encryptedUserKey))
));
if (userKeyBytes.length < 64) throw new Error('Invalid passkey vault key');
return {
symEncKey: bytesToBase64(userKeyBytes.slice(0, 32)),
symMacKey: bytesToBase64(userKeyBytes.slice(32, 64)),
};
}
+165
View File
@@ -2,11 +2,13 @@ import { bytesToBase64, decryptBw, encryptBw, hkdfExpand, pbkdf2 } from '../cryp
import { t, translateServerError } from '../i18n';
import type { AuthorizedDevice } from '../types';
import type {
AccountPasskeyCredential,
Profile,
SessionState,
TokenError,
TokenSuccess,
} from '../types';
import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys';
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
const SESSION_KEY = 'nodewarden.web.session.v4';
@@ -281,6 +283,40 @@ export async function loginWithPassword(
return json;
}
export async function getAccountPasskeyAssertionOptions(): Promise<{ options: unknown; token: string }> {
const resp = await fetch('/identity/accounts/webauthn/assertion-options');
if (!resp.ok) {
const json = await parseJson<TokenError>(resp);
throw new Error(translateServerError(json?.error_description || json?.error, t('txt_login_failed')));
}
const body = (await parseJson<{ options?: unknown; token?: string }>(resp)) || {};
if (!body.options || !body.token) throw new Error('Invalid passkey assertion options');
return { options: body.options, token: body.token };
}
export async function loginWithAccountPasskeyAssertion(assertion: AccountPasskeyAssertion): Promise<TokenSuccess | TokenError> {
const body = new URLSearchParams();
body.set('grant_type', 'webauthn');
body.set('token', assertion.token);
body.set('deviceResponse', JSON.stringify(assertion.deviceResponse));
body.set('scope', 'api offline_access');
body.set('deviceIdentifier', getOrCreateDeviceIdentifier());
body.set('deviceName', guessDeviceName());
body.set('deviceType', '14');
const resp = await fetch('/identity/connect/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
[WEB_SESSION_HEADER]: '1',
},
body: body.toString(),
});
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
if (!resp.ok) return json;
return json;
}
function isTransientRefreshStatus(status: number): boolean {
return status === 0 || status === 429 || status >= 500;
}
@@ -605,6 +641,135 @@ export async function verifyMasterPassword(
}
}
function normalizeAccountPasskeyCredential(raw: any): AccountPasskeyCredential {
return {
id: String(raw?.id || raw?.Id || ''),
name: String(raw?.name || raw?.Name || ''),
prfStatus: Number(raw?.prfStatus ?? raw?.PrfStatus ?? 2) as 0 | 1 | 2,
encryptedPublicKey: raw?.encryptedPublicKey ?? raw?.EncryptedPublicKey ?? null,
encryptedUserKey: raw?.encryptedUserKey ?? raw?.EncryptedUserKey ?? null,
creationDate: raw?.creationDate ?? raw?.CreationDate,
revisionDate: raw?.revisionDate ?? raw?.RevisionDate,
};
}
export async function listAccountPasskeys(authedFetch: AuthedFetch): Promise<AccountPasskeyCredential[]> {
const resp = await authedFetch('/api/webauthn');
if (!resp.ok) throw new Error('Failed to load account passkeys');
const body = (await parseJson<{ data?: unknown[]; Data?: unknown[] }>(resp)) || {};
const rows = Array.isArray(body.data) ? body.data : Array.isArray(body.Data) ? body.Data : [];
return rows.map(normalizeAccountPasskeyCredential).filter((item) => item.id);
}
export async function getAccountPasskeyAttestationOptions(
authedFetch: AuthedFetch,
masterPasswordHash: string
): Promise<{ options: unknown; token: string }> {
const resp = await authedFetch('/api/webauthn/attestation-options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_master_password_verify_failed')));
}
const body = (await parseJson<{ options?: unknown; token?: string }>(resp)) || {};
if (!body.options || !body.token) throw new Error('Invalid passkey creation options');
return { options: body.options, token: body.token };
}
export async function getAccountPasskeyUpdateAssertionOptions(
authedFetch: AuthedFetch,
masterPasswordHash: string,
credentialId?: string
): Promise<{ options: unknown; token: string }> {
const resp = await authedFetch('/api/webauthn/assertion-options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash, credentialId }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_master_password_verify_failed')));
}
const body = (await parseJson<{ options?: unknown; token?: string }>(resp)) || {};
if (!body.options || !body.token) throw new Error('Invalid passkey assertion options');
return { options: body.options, token: body.token };
}
export async function saveAccountPasskey(
authedFetch: AuthedFetch,
payload: {
name: string;
token: string;
deviceResponse: unknown;
supportsPrf: boolean;
keySet?: AccountPasskeyPrfKeySet | null;
}
): Promise<AccountPasskeyCredential> {
const resp = await authedFetch('/api/webauthn', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: payload.name,
token: payload.token,
deviceResponse: payload.deviceResponse,
supportsPrf: payload.supportsPrf,
encryptedUserKey: payload.keySet?.encryptedUserKey,
encryptedPublicKey: payload.keySet?.encryptedPublicKey,
encryptedPrivateKey: payload.keySet?.encryptedPrivateKey,
}),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_save_profile_failed')));
}
const body = await parseJson<unknown>(resp);
return normalizeAccountPasskeyCredential(body);
}
export async function enableAccountPasskeyDirectUnlock(
authedFetch: AuthedFetch,
payload: {
token: string;
deviceResponse: unknown;
keySet: AccountPasskeyPrfKeySet;
}
): Promise<void> {
const resp = await authedFetch('/api/webauthn', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: payload.token,
deviceResponse: payload.deviceResponse,
encryptedUserKey: payload.keySet.encryptedUserKey,
encryptedPublicKey: payload.keySet.encryptedPublicKey,
encryptedPrivateKey: payload.keySet.encryptedPrivateKey,
}),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_save_profile_failed')));
}
}
export async function deleteAccountPasskey(
authedFetch: AuthedFetch,
id: string,
masterPasswordHash: string
): Promise<void> {
const resp = await authedFetch(`/api/webauthn/${encodeURIComponent(id)}/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_delete_item_failed')));
}
}
export async function getVaultRevisionDate(authedFetch: AuthedFetch): Promise<number> {
const resp = await authedFetch('/api/accounts/revision-date');
if (!resp.ok) {
+1
View File
@@ -96,6 +96,7 @@ export interface AdminBackupImportCounts {
users: number;
domainSettings?: number;
userRevisions: number;
webauthnCredentials?: number;
folders: number;
ciphers: number;
attachments: number;
+107 -19
View File
@@ -1,15 +1,21 @@
import {
createAuthedFetch,
deriveLoginHashLocally,
getAccountPasskeyAssertionOptions,
getProfile,
loadProfileSnapshot,
loadSession,
loginWithAccountPasskeyAssertion,
loginWithPassword,
refreshAccessToken,
recoverTwoFactor,
registerAccount,
unlockVaultKey,
} from '@/lib/api/auth';
import {
assertAccountPasskey,
unlockVaultKeyWithAccountPasskeyPrf,
} from '@/lib/account-passkeys';
import { readInviteCodeFromUrl } from '@/lib/app-support';
import { t, translateServerError } from '@/lib/i18n';
import {
@@ -21,7 +27,7 @@ import {
unlockOfflineVaultWithMasterKey,
} from '@/lib/offline-auth';
import { probeNodeWardenService } from '@/lib/network-status';
import type { AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
import type { AccountPasskeyPrfOption, AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
export interface PendingTotp {
email: string;
@@ -30,6 +36,12 @@ export interface PendingTotp {
kdfIterations: number;
}
export interface PendingPasskeyPassword {
token: TokenSuccess;
email: string;
kdfIterations: number;
}
export type JwtUnsafeReason = 'missing' | 'default' | 'too_short';
export interface BootstrapAppResult {
@@ -61,6 +73,11 @@ export type PasswordLoginResult =
| { kind: 'totp'; pendingTotp: PendingTotp }
| { kind: 'error'; message: string };
export type PasskeyLoginResult =
| { kind: 'success'; login: CompletedLogin }
| { kind: 'password'; pendingPasskeyPassword: PendingPasskeyPassword }
| { kind: 'error'; message: string };
export interface RecoverTwoFactorResult {
login: CompletedLogin | null;
newRecoveryCode: string | null;
@@ -92,6 +109,7 @@ async function maybeRefreshSession(session: SessionState): Promise<SessionState
const refreshed = await refreshAccessToken(session);
if (!refreshed.ok) {
if (refreshed.transient) return session;
return session.accessToken && exp !== null && exp > nowSeconds ? session : null;
}
@@ -107,16 +125,6 @@ function browserReportsOffline(): boolean {
return typeof navigator !== 'undefined' && navigator.onLine === false;
}
function createTimeoutAbortController(timeoutMs: number): { controller: AbortController; cancel: () => void } | null {
if (typeof AbortController === 'undefined') return null;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
return {
controller,
cancel: () => clearTimeout(timer),
};
}
function readWindowBootstrap(): WebBootstrapResponse {
if (typeof window === 'undefined') return {};
const raw = (window as Window & { __NW_BOOT__?: WebBootstrapResponse }).__NW_BOOT__;
@@ -270,8 +278,10 @@ export async function hydrateLockedSession(
session: SessionState,
fallbackProfile: Profile | null = null
): Promise<{ session: SessionState | null; profile: Profile | null }> {
if (hasOfflineUnlockRecord(session.email)) {
const serviceReachable = await probeNodeWardenService();
const hasOfflineUnlock = hasOfflineUnlockRecord(session.email);
let serviceReachable = true;
if (hasOfflineUnlock) {
serviceReachable = await probeNodeWardenService();
if (!serviceReachable) {
return {
session,
@@ -282,7 +292,7 @@ export async function hydrateLockedSession(
const refreshedSession = await maybeRefreshSession(session);
if (!refreshedSession?.accessToken) {
if (hasOfflineUnlockRecord(session.email)) {
if (hasOfflineUnlock && !serviceReachable) {
return {
session,
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
@@ -345,6 +355,36 @@ export async function completeLogin(
};
}
function readPasskeyPrfOption(token: TokenSuccess): AccountPasskeyPrfOption | null {
const options = (token.UserDecryptionOptions || token.userDecryptionOptions || null) as any;
return options?.WebAuthnPrfOption || options?.webAuthnPrfOption || null;
}
async function completeLoginWithVaultKeys(
token: TokenSuccess,
email: string,
keys: { symEncKey: string; symMacKey: string }
): Promise<CompletedLogin> {
const normalizedEmail = email.trim().toLowerCase();
const fallbackProfile = loadProfileSnapshot(normalizedEmail);
const baseSession: SessionState = {
accessToken: token.access_token,
refreshToken: token.refresh_token,
email: normalizedEmail,
authMode: token.web_session ? 'web-cookie' : 'token',
};
const tempFetch = createAuthedFetch(
() => baseSession,
() => {}
);
const profile = buildTransientProfile(token, normalizedEmail, fallbackProfile);
return {
session: { ...baseSession, ...keys },
profile,
profilePromise: getProfile(tempFetch),
};
}
export async function performPasswordLogin(
email: string,
password: string,
@@ -380,6 +420,58 @@ export async function performPasswordLogin(
};
}
export async function performPasskeyLogin(fallbackIterations: number): Promise<PasskeyLoginResult> {
try {
const options = await getAccountPasskeyAssertionOptions();
const assertion = await assertAccountPasskey(options);
const token = await loginWithAccountPasskeyAssertion(assertion);
if (!('access_token' in token) || !token.access_token) {
const tokenError = token as { error_description?: string; error?: string };
return {
kind: 'error',
message: translateServerError(tokenError.error_description || tokenError.error, t('txt_login_failed')),
};
}
const email = (decodeAccessTokenClaims(token.access_token).email || '').trim().toLowerCase();
if (!email) {
return { kind: 'error', message: t('txt_login_failed') };
}
const prfOption = readPasskeyPrfOption(token);
if (prfOption && assertion.prfKey) {
const keys = await unlockVaultKeyWithAccountPasskeyPrf(assertion.prfKey, prfOption);
return {
kind: 'success',
login: await completeLoginWithVaultKeys(token, email, keys),
};
}
return {
kind: 'password',
pendingPasskeyPassword: {
token,
email,
kdfIterations: kdfIterationsFromLogin(token, fallbackIterations),
},
};
} catch (error) {
return {
kind: 'error',
message: error instanceof Error ? translateServerError(error.message, error.message) : t('txt_login_failed'),
};
}
}
export async function completePasskeyPasswordLogin(
pending: PendingPasskeyPassword,
password: string
): Promise<CompletedLogin> {
const derived = await deriveLoginHashLocally(pending.email, password, pending.kdfIterations);
return completeLogin(pending.token, pending.email, derived.masterKey, pending.kdfIterations);
}
export async function performTotpLogin(
pendingTotp: PendingTotp,
totpCode: string,
@@ -479,22 +571,18 @@ export async function performUnlock(
}
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
const abortable = hasOfflineUnlock ? createTimeoutAbortController(2500) : null;
try {
token = await loginWithPassword(normalizedEmail, derived.hash, {
useRememberToken: true,
signal: abortable?.controller.signal,
});
} catch {
if (hasOfflineUnlock) {
if (hasOfflineUnlock && (browserReportsOffline() || !(await probeNodeWardenService()))) {
return unlockOffline();
}
return {
kind: 'error',
message: t('txt_unlock_failed_master_password_is_incorrect'),
};
} finally {
abortable?.cancel();
}
if ('access_token' in token && token.access_token) {
+23
View File
@@ -631,6 +631,29 @@ const en: Record<string, string> = {
"txt_passkey": "Passkey",
"txt_passkeys": "Passkeys",
"txt_passkey_created_at_value": "Created on {value}",
"txt_account_passkey": "Account passkey",
"txt_account_passkeys": "Account passkeys",
"txt_account_passkey_mode": "Unlock mode",
"txt_account_passkey_direct_unlock_mode": "Direct vault unlock",
"txt_account_passkey_direct_unlock_help": "Unlocks the vault with this passkey when PRF is available.",
"txt_account_passkey_login_only_help": "Verifies the account with passkey, then asks for master password.",
"txt_account_passkey_name_placeholder": "This device",
"txt_account_passkey_saved": "Account passkey saved",
"txt_account_passkey_deleted": "Account passkey deleted",
"txt_account_passkeys_load_failed": "Failed to load account passkeys",
"txt_account_passkey_not_found": "Account passkey not found",
"txt_account_passkey_prf_not_available": "This passkey cannot return a PRF key",
"txt_account_passkey_direct_unlock_enabled": "Direct vault unlock enabled",
"txt_add_account_passkey": "Add account passkey",
"txt_delete_account_passkey": "Delete account passkey",
"txt_direct_unlock": "Direct unlock",
"txt_enable_passkey_direct_unlock": "Enable direct unlock",
"txt_login_only": "Login only",
"txt_login_with_passkey": "Log in with passkey",
"txt_no_account_passkeys": "No account passkeys",
"txt_passkey_name": "Passkey name",
"txt_passkey_requires_master_password": "Passkey verified. Enter your master password to unlock the vault.",
"txt_prf_not_supported": "PRF not supported",
"txt_phone": "Phone",
"txt_please_input_email_and_password": "Please input email and password",
"txt_please_input_master_password": "Please input master password",
+23
View File
@@ -631,6 +631,29 @@ const es: Record<string, string> = {
"txt_passkey": "Clave de acceso",
"txt_passkeys": "Claves de acceso",
"txt_passkey_created_at_value": "Creado el {value}",
"txt_account_passkey": "Passkey de cuenta",
"txt_account_passkeys": "Passkeys de cuenta",
"txt_account_passkey_mode": "Modo de desbloqueo",
"txt_account_passkey_direct_unlock_mode": "Desbloqueo directo",
"txt_account_passkey_direct_unlock_help": "Desbloquea la bóveda con esta passkey cuando PRF está disponible.",
"txt_account_passkey_login_only_help": "Verifica la cuenta con passkey y luego pide la contraseña maestra.",
"txt_account_passkey_name_placeholder": "Este dispositivo",
"txt_account_passkey_saved": "Passkey de cuenta guardada",
"txt_account_passkey_deleted": "Passkey de cuenta eliminada",
"txt_account_passkeys_load_failed": "Error al cargar passkeys de cuenta",
"txt_account_passkey_not_found": "Passkey de cuenta no encontrada",
"txt_account_passkey_prf_not_available": "Esta passkey no puede devolver una clave PRF",
"txt_account_passkey_direct_unlock_enabled": "Desbloqueo directo activado",
"txt_add_account_passkey": "Añadir passkey de cuenta",
"txt_delete_account_passkey": "Eliminar passkey de cuenta",
"txt_direct_unlock": "Desbloqueo directo",
"txt_enable_passkey_direct_unlock": "Activar desbloqueo directo",
"txt_login_only": "Solo inicio de sesión",
"txt_login_with_passkey": "Iniciar sesión con passkey",
"txt_no_account_passkeys": "Sin passkeys de cuenta",
"txt_passkey_name": "Nombre de passkey",
"txt_passkey_requires_master_password": "Passkey verificada. Introduzca su contraseña maestra para desbloquear la bóveda.",
"txt_prf_not_supported": "PRF no compatible",
"txt_phone": "Teléfono",
"txt_please_input_email_and_password": "Por favor, introduzca correo y contraseña",
"txt_please_input_master_password": "Por favor, introduzca contraseña maestra",
+23
View File
@@ -631,6 +631,29 @@ const ru: Record<string, string> = {
"txt_passkey": "Ключ доступа",
"txt_passkeys": "Ключи доступа",
"txt_passkey_created_at_value": "Создано {value}",
"txt_account_passkey": "Passkey аккаунта",
"txt_account_passkeys": "Passkeys аккаунта",
"txt_account_passkey_mode": "Режим разблокировки",
"txt_account_passkey_direct_unlock_mode": "Прямая разблокировка",
"txt_account_passkey_direct_unlock_help": "Разблокирует хранилище этой passkey, когда доступен PRF.",
"txt_account_passkey_login_only_help": "Проверяет аккаунт passkey, затем запрашивает мастер-пароль.",
"txt_account_passkey_name_placeholder": "Это устройство",
"txt_account_passkey_saved": "Passkey аккаунта сохранена",
"txt_account_passkey_deleted": "Passkey аккаунта удалена",
"txt_account_passkeys_load_failed": "Не удалось загрузить passkeys аккаунта",
"txt_account_passkey_not_found": "Passkey аккаунта не найдена",
"txt_account_passkey_prf_not_available": "Эта passkey не может вернуть PRF-ключ",
"txt_account_passkey_direct_unlock_enabled": "Прямая разблокировка включена",
"txt_add_account_passkey": "Добавить passkey аккаунта",
"txt_delete_account_passkey": "Удалить passkey аккаунта",
"txt_direct_unlock": "Прямая разблокировка",
"txt_enable_passkey_direct_unlock": "Включить прямую разблокировку",
"txt_login_only": "Только вход",
"txt_login_with_passkey": "Войти с passkey",
"txt_no_account_passkeys": "Нет passkeys аккаунта",
"txt_passkey_name": "Название passkey",
"txt_passkey_requires_master_password": "Passkey подтвержден. Введите мастер-пароль, чтобы разблокировать хранилище.",
"txt_prf_not_supported": "PRF не поддерживается",
"txt_phone": "Телефон",
"txt_please_input_email_and_password": "Пожалуйста, введите адрес электронной почты и пароль",
"txt_please_input_master_password": "Пожалуйста, введите мастер-пароль",
+23
View File
@@ -631,6 +631,29 @@ const zhCN: Record<string, string> = {
"txt_passkey": "通行密钥",
"txt_passkeys": "通行密钥",
"txt_passkey_created_at_value": "创建于 {value}",
"txt_account_passkey": "账号通行密钥",
"txt_account_passkeys": "账号通行密钥",
"txt_account_passkey_mode": "解锁模式",
"txt_account_passkey_direct_unlock_mode": "直接解锁密码库",
"txt_account_passkey_direct_unlock_help": "支持 PRF 时,用这把通行密钥直接解锁密码库。",
"txt_account_passkey_login_only_help": "先用通行密钥验证账号,再输入主密码解锁。",
"txt_account_passkey_name_placeholder": "这台设备",
"txt_account_passkey_saved": "账号通行密钥已保存",
"txt_account_passkey_deleted": "账号通行密钥已删除",
"txt_account_passkeys_load_failed": "加载账号通行密钥失败",
"txt_account_passkey_not_found": "未找到账号通行密钥",
"txt_account_passkey_prf_not_available": "这把通行密钥无法返回 PRF 密钥",
"txt_account_passkey_direct_unlock_enabled": "已开启直接解锁",
"txt_add_account_passkey": "添加账号通行密钥",
"txt_delete_account_passkey": "删除账号通行密钥",
"txt_direct_unlock": "直接解锁",
"txt_enable_passkey_direct_unlock": "开启直接解锁",
"txt_login_only": "仅登录",
"txt_login_with_passkey": "使用 Passkey 登录",
"txt_no_account_passkeys": "暂无账号通行密钥",
"txt_passkey_name": "通行密钥名称",
"txt_passkey_requires_master_password": "Passkey 已验证,请输入主密码解锁密码库。",
"txt_prf_not_supported": "不支持 PRF",
"txt_phone": "电话",
"txt_please_input_email_and_password": "请输入邮箱和密码",
"txt_please_input_master_password": "请输入主密码",
+23
View File
@@ -631,6 +631,29 @@ const zhTW: Record<string, string> = {
"txt_passkey": "通行密鑰",
"txt_passkeys": "通行密鑰",
"txt_passkey_created_at_value": "創建於 {value}",
"txt_account_passkey": "賬號通行密鑰",
"txt_account_passkeys": "賬號通行密鑰",
"txt_account_passkey_mode": "解鎖模式",
"txt_account_passkey_direct_unlock_mode": "直接解鎖密碼庫",
"txt_account_passkey_direct_unlock_help": "支持 PRF 時,用這把通行密鑰直接解鎖密碼庫。",
"txt_account_passkey_login_only_help": "先用通行密鑰驗證賬號,再輸入主密碼解鎖。",
"txt_account_passkey_name_placeholder": "這台設備",
"txt_account_passkey_saved": "賬號通行密鑰已保存",
"txt_account_passkey_deleted": "賬號通行密鑰已刪除",
"txt_account_passkeys_load_failed": "加載賬號通行密鑰失敗",
"txt_account_passkey_not_found": "未找到賬號通行密鑰",
"txt_account_passkey_prf_not_available": "這把通行密鑰無法返回 PRF 密鑰",
"txt_account_passkey_direct_unlock_enabled": "已開啟直接解鎖",
"txt_add_account_passkey": "添加賬號通行密鑰",
"txt_delete_account_passkey": "刪除賬號通行密鑰",
"txt_direct_unlock": "直接解鎖",
"txt_enable_passkey_direct_unlock": "開啟直接解鎖",
"txt_login_only": "僅登錄",
"txt_login_with_passkey": "使用 Passkey 登錄",
"txt_no_account_passkeys": "暫無賬號通行密鑰",
"txt_passkey_name": "通行密鑰名稱",
"txt_passkey_requires_master_password": "Passkey 已驗證,請輸入主密碼解鎖密碼庫。",
"txt_prf_not_supported": "不支持 PRF",
"txt_phone": "電話",
"txt_please_input_email_and_password": "請輸入郵箱和密碼",
"txt_please_input_master_password": "請輸入主密碼",
+6 -4
View File
@@ -6,14 +6,14 @@ const listeners = new Set<(status: NetworkStatus) => void>();
let currentStatus: NetworkStatus = getInitialNetworkStatus();
let pendingProbe: Promise<boolean> | null = null;
let lastProbeAt = 0;
let lastProbeResult = false;
let lastProbeResult = currentStatus === 'online';
export function browserReportsOffline(): boolean {
return typeof navigator !== 'undefined' && navigator.onLine === false;
}
export function getInitialNetworkStatus(): NetworkStatus {
return 'offline';
return browserReportsOffline() ? 'offline' : 'online';
}
export function getCurrentNetworkStatus(): NetworkStatus {
@@ -51,7 +51,7 @@ export async function probeNodeWardenService(): Promise<boolean> {
: 0;
pendingProbe = (async () => {
const response = await fetch(`/api/web-bootstrap?statusProbe=${Date.now()}`, {
await fetch(`/api/web-bootstrap?statusProbe=${Date.now()}`, {
method: 'GET',
cache: 'no-store',
headers: {
@@ -61,7 +61,9 @@ export async function probeNodeWardenService(): Promise<boolean> {
},
signal: controller?.signal,
});
return response.ok;
// Any same-origin HTTP response proves the server is reachable. A 4xx/5xx
// response may be an application problem, but it is not offline mode.
return true;
})()
.catch(() => false)
.then((result) => {
+1 -1
View File
@@ -43,7 +43,7 @@ function parseRecord(raw: string | null): OfflineUnlockRecord | null {
name: email,
key: '',
privateKey: null,
role: 'user',
role: 'user' as const,
};
return {
version: 1,
+31
View File
@@ -328,6 +328,37 @@ export interface TokenError {
TwoFactorProviders?: unknown;
}
export interface AccountPasskeyCredential {
id: string;
name: string;
prfStatus: 0 | 1 | 2;
encryptedPublicKey?: string | null;
encryptedUserKey?: string | null;
creationDate?: string;
revisionDate?: string;
}
export interface AccountPasskeyAssertionOptionsResponse {
options: PublicKeyCredentialRequestOptions;
token: string;
}
export interface AccountPasskeyCreationOptionsResponse {
options: PublicKeyCredentialCreationOptions;
token: string;
}
export interface AccountPasskeyPrfOption {
EncryptedPrivateKey?: string;
EncryptedUserKey?: string;
CredentialId?: string;
Transports?: string[];
encryptedPrivateKey?: string;
encryptedUserKey?: string;
credentialId?: string;
transports?: string[];
}
export interface ToastMessage {
id: string;
type: 'success' | 'error' | 'warning';