mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-23 05:50:14 +00:00
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:
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -96,6 +96,7 @@ export interface AdminBackupImportCounts {
|
||||
users: number;
|
||||
domainSettings?: number;
|
||||
userRevisions: number;
|
||||
webauthnCredentials?: number;
|
||||
folders: number;
|
||||
ciphers: number;
|
||||
attachments: number;
|
||||
|
||||
+107
-19
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Пожалуйста, введите мастер-пароль",
|
||||
|
||||
@@ -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": "请输入主密码",
|
||||
|
||||
@@ -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,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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user