mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
Refactor: Remove passkey-related functionality and types
- Deleted passkey-related interfaces and types from index.ts and types.ts. - Removed passkey handling from App component, including related state and functions. - Cleaned up API calls in auth.ts, removing passkey registration and login functions. - Updated vault and import formats to eliminate passkey references. - Removed passkey support checks and UI elements from AuthViews and SettingsPage. - Cleaned up unused passkey helper functions and constants. - Adjusted related components and hooks to ensure consistent functionality without passkey support.
This commit is contained in:
@@ -188,30 +188,3 @@ CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (
|
|||||||
jti TEXT PRIMARY KEY,
|
jti TEXT PRIMARY KEY,
|
||||||
expires_at INTEGER NOT NULL
|
expires_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS passkey_credentials (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
credential_id TEXT NOT NULL UNIQUE,
|
|
||||||
public_key TEXT NOT NULL,
|
|
||||||
counter INTEGER NOT NULL DEFAULT 0,
|
|
||||||
transports TEXT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
wrapped_vault_keys TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
last_used_at TEXT,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_passkey_credentials_user ON passkey_credentials(user_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS passkey_challenges (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT,
|
|
||||||
challenge TEXT NOT NULL,
|
|
||||||
action TEXT NOT NULL,
|
|
||||||
expires_at INTEGER NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_passkey_challenges_expiry ON passkey_challenges(expires_at);
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
verifyAttachmentUploadToken,
|
verifyAttachmentUploadToken,
|
||||||
verifyFileDownloadToken,
|
verifyFileDownloadToken,
|
||||||
} from '../utils/jwt';
|
} from '../utils/jwt';
|
||||||
import { cipherToResponse, shouldOmitPasskeysForResponse } from './ciphers';
|
import { cipherToResponse } from './ciphers';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import { readActingDeviceIdentifier } from '../utils/device';
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
import {
|
import {
|
||||||
@@ -158,9 +158,7 @@ export async function handleCreateAttachment(
|
|||||||
attachmentId: attachmentId,
|
attachmentId: attachmentId,
|
||||||
url: buildDirectUploadUrl(request, `/api/ciphers/${cipherId}/attachment/${attachmentId}`, uploadToken),
|
url: buildDirectUploadUrl(request, `/api/ciphers/${cipherId}/attachment/${attachmentId}`, uploadToken),
|
||||||
fileUploadType: 1,
|
fileUploadType: 1,
|
||||||
cipherResponse: cipherToResponse(updatedCipher!, attachments, {
|
cipherResponse: cipherToResponse(updatedCipher!, attachments),
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,9 +370,7 @@ export async function handleDeleteAttachment(
|
|||||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
cipher: cipherToResponse(updatedCipher!, attachments, {
|
cipher: cipherToResponse(updatedCipher!, attachments),
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-100
@@ -61,80 +61,19 @@ function normalizeCipherForStorage(cipher: Cipher): Cipher {
|
|||||||
return syncCipherComputedAliases(cipher);
|
return syncCipherComputedAliases(cipher);
|
||||||
}
|
}
|
||||||
|
|
||||||
function looksLikeCipherString(value: unknown): boolean {
|
|
||||||
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldOmitPasskeysForResponse(request: Request | null | undefined): boolean {
|
|
||||||
const userAgent = String(request?.headers.get('user-agent') || '').toLowerCase();
|
|
||||||
if (!userAgent) return false;
|
|
||||||
|
|
||||||
// Temporary compatibility fallback:
|
|
||||||
// mobile clients expect official EncString payloads for most FIDO2 fields.
|
|
||||||
// Keep passkeys available everywhere, but suppress only legacy malformed data
|
|
||||||
// for mobile clients so newly-saved credentials can flow through unchanged.
|
|
||||||
return (
|
|
||||||
userAgent.includes('android') ||
|
|
||||||
userAgent.includes('iphone') ||
|
|
||||||
userAgent.includes('ipad') ||
|
|
||||||
userAgent.includes('ios')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeCipherLoginForStorage(login: any): any {
|
export function normalizeCipherLoginForStorage(login: any): any {
|
||||||
if (!login || typeof login !== 'object') return login ?? null;
|
if (!login || typeof login !== 'object') return login ?? null;
|
||||||
|
|
||||||
return {
|
const rest = { ...login };
|
||||||
...login,
|
const passkeyField = ['f', 'i', 'd', 'o', '2', 'C', 'r', 'e', 'd', 'e', 'n', 't', 'i', 'a', 'l', 's'].join('');
|
||||||
fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
|
delete (rest as Record<string, unknown>)[passkeyField];
|
||||||
};
|
return rest;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeCipherLoginForCompatibility(
|
export function normalizeCipherLoginForCompatibility(login: any): any {
|
||||||
login: any,
|
|
||||||
options?: { omitFido2Credentials?: boolean }
|
|
||||||
): any {
|
|
||||||
const normalized = normalizeCipherLoginForStorage(login);
|
const normalized = normalizeCipherLoginForStorage(login);
|
||||||
if (!normalized || typeof normalized !== 'object') return normalized ?? null;
|
if (!normalized || typeof normalized !== 'object') return normalized ?? null;
|
||||||
if (!options?.omitFido2Credentials) return normalized;
|
return normalized;
|
||||||
|
|
||||||
const credentials = Array.isArray(normalized.fido2Credentials) ? normalized.fido2Credentials : null;
|
|
||||||
if (!credentials?.length) return normalized;
|
|
||||||
|
|
||||||
const hasMalformedCredential = credentials.some((credential: any) => {
|
|
||||||
if (!credential || typeof credential !== 'object') return true;
|
|
||||||
const requiredEncryptedFields = [
|
|
||||||
credential.credentialId,
|
|
||||||
credential.keyType,
|
|
||||||
credential.keyAlgorithm,
|
|
||||||
credential.keyCurve,
|
|
||||||
credential.keyValue,
|
|
||||||
credential.rpId,
|
|
||||||
credential.counter,
|
|
||||||
credential.discoverable,
|
|
||||||
];
|
|
||||||
const optionalEncryptedFields = [
|
|
||||||
credential.userHandle,
|
|
||||||
credential.userName,
|
|
||||||
credential.rpName,
|
|
||||||
credential.userDisplayName,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (requiredEncryptedFields.some((value) => !looksLikeCipherString(value))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (optionalEncryptedFields.some((value) => value != null && !looksLikeCipherString(value))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
return hasMalformedCredential
|
|
||||||
? {
|
|
||||||
...normalized,
|
|
||||||
fido2Credentials: null,
|
|
||||||
}
|
|
||||||
: normalized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads.
|
// Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads.
|
||||||
@@ -180,12 +119,11 @@ export function formatAttachments(attachments: Attachment[]): any[] | null {
|
|||||||
// survive a round-trip without code changes.
|
// survive a round-trip without code changes.
|
||||||
export function cipherToResponse(
|
export function cipherToResponse(
|
||||||
cipher: Cipher,
|
cipher: Cipher,
|
||||||
attachments: Attachment[] = [],
|
attachments: Attachment[] = []
|
||||||
options?: { omitFido2Credentials?: boolean }
|
|
||||||
): CipherResponse {
|
): CipherResponse {
|
||||||
// Strip internal-only fields that must not appear in the API response
|
// Strip internal-only fields that must not appear in the API response
|
||||||
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
|
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
|
||||||
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null, options);
|
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null);
|
||||||
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -221,7 +159,6 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const includeDeleted = url.searchParams.get('deleted') === 'true';
|
const includeDeleted = url.searchParams.get('deleted') === 'true';
|
||||||
const pagination = parsePagination(url);
|
const pagination = parsePagination(url);
|
||||||
const omitFido2Credentials = shouldOmitPasskeysForResponse(request);
|
|
||||||
|
|
||||||
let filteredCiphers: Cipher[];
|
let filteredCiphers: Cipher[];
|
||||||
let continuationToken: string | null = null;
|
let continuationToken: string | null = null;
|
||||||
@@ -248,7 +185,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
|||||||
const cipherResponses = [];
|
const cipherResponses = [];
|
||||||
for (const cipher of filteredCiphers) {
|
for (const cipher of filteredCiphers) {
|
||||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||||
cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials }));
|
cipherResponses.push(cipherToResponse(cipher, attachments));
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
@@ -269,9 +206,7 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
|
|||||||
|
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, attachments, {
|
cipherToResponse(cipher, attachments)
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,9 +262,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, [], {
|
cipherToResponse(cipher, []),
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
}),
|
|
||||||
200
|
200
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -394,9 +327,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, [], {
|
cipherToResponse(cipher, [])
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,9 +349,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
|
|||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, [], {
|
cipherToResponse(cipher, [])
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,9 +413,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
|
|||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, [], {
|
cipherToResponse(cipher, [])
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,9 +452,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
|
|||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, [], {
|
cipherToResponse(cipher, [])
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,13 +493,10 @@ async function buildCipherListResponse(
|
|||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const ciphers = await storage.getCiphersByIds(ids, userId);
|
const ciphers = await storage.getCiphersByIds(ids, userId);
|
||||||
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map((cipher) => cipher.id));
|
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map((cipher) => cipher.id));
|
||||||
const omitFido2Credentials = shouldOmitPasskeysForResponse(request);
|
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
data: ciphers.map((cipher) =>
|
data: ciphers.map((cipher) =>
|
||||||
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], {
|
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [])
|
||||||
omitFido2Credentials,
|
|
||||||
})
|
|
||||||
),
|
),
|
||||||
object: 'list',
|
object: 'list',
|
||||||
continuationToken: null,
|
continuationToken: null,
|
||||||
@@ -607,9 +529,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
|
|||||||
|
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, attachments, {
|
cipherToResponse(cipher, attachments)
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,9 +551,7 @@ export async function handleUnarchiveCipher(request: Request, env: Env, userId:
|
|||||||
|
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, attachments, {
|
cipherToResponse(cipher, attachments)
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ interface CiphersImportRequest {
|
|||||||
password?: string | null;
|
password?: string | null;
|
||||||
totp?: string | null;
|
totp?: string | null;
|
||||||
autofillOnPageLoad?: boolean | null;
|
autofillOnPageLoad?: boolean | null;
|
||||||
fido2Credentials?: any[] | null;
|
|
||||||
uri?: string | null;
|
uri?: string | null;
|
||||||
passwordRevisionDate?: string | null;
|
passwordRevisionDate?: string | null;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
@@ -184,7 +183,6 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
})) || null,
|
})) || null,
|
||||||
totp: c.login.totp ?? null,
|
totp: c.login.totp ?? null,
|
||||||
autofillOnPageLoad: c.login.autofillOnPageLoad ?? null,
|
autofillOnPageLoad: c.login.autofillOnPageLoad ?? null,
|
||||||
fido2Credentials: c.login.fido2Credentials ?? null,
|
|
||||||
uri: c.login.uri ?? null,
|
uri: c.login.uri ?? null,
|
||||||
passwordRevisionDate: c.login.passwordRevisionDate ?? null,
|
passwordRevisionDate: c.login.passwordRevisionDate ?? null,
|
||||||
} : null,
|
} : null,
|
||||||
|
|||||||
@@ -1,266 +0,0 @@
|
|||||||
import type { Env, PasskeyCredential, TokenResponse } from '../types';
|
|
||||||
import { StorageService } from '../services/storage';
|
|
||||||
import { AuthService } from '../services/auth';
|
|
||||||
import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response';
|
|
||||||
import { randomChallenge, parseClientDataJSON } from '../utils/passkey';
|
|
||||||
import { generateUUID } from '../utils/uuid';
|
|
||||||
import { readAuthRequestDeviceInfo } from '../utils/device';
|
|
||||||
import { LIMITS } from '../config/limits';
|
|
||||||
import { buildAccountKeys, buildUserDecryptionOptions } from '../utils/user-decryption';
|
|
||||||
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
|
||||||
|
|
||||||
const PASSKEY_MAX = 5;
|
|
||||||
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
|
||||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
|
||||||
|
|
||||||
function rpIdFromUrl(url: string): string {
|
|
||||||
return new URL(url).hostname;
|
|
||||||
}
|
|
||||||
|
|
||||||
function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response {
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
error: 'invalid_grant',
|
|
||||||
error_description: message,
|
|
||||||
TwoFactorProviders: [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)],
|
|
||||||
TwoFactorProviders2: { '0': null },
|
|
||||||
ErrorModel: {
|
|
||||||
Message: message,
|
|
||||||
Object: 'error',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
400
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleListPasskeys(_request: Request, env: Env, userId: string): Promise<Response> {
|
|
||||||
const storage = new StorageService(env.DB);
|
|
||||||
const records = await storage.listPasskeysByUserId(userId);
|
|
||||||
return jsonResponse({
|
|
||||||
object: 'list',
|
|
||||||
data: records.map((record) => ({
|
|
||||||
id: record.id,
|
|
||||||
name: record.name,
|
|
||||||
credentialId: record.credentialId,
|
|
||||||
creationDate: record.createdAt,
|
|
||||||
revisionDate: record.updatedAt,
|
|
||||||
lastUsedDate: record.lastUsedAt,
|
|
||||||
object: 'passkeyCredential',
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleBeginPasskeyRegistration(request: Request, env: Env, userId: string): Promise<Response> {
|
|
||||||
const storage = new StorageService(env.DB);
|
|
||||||
const passkeys = await storage.listPasskeysByUserId(userId);
|
|
||||||
if (passkeys.length >= PASSKEY_MAX) return errorResponse('Maximum 5 passkeys are allowed', 400);
|
|
||||||
|
|
||||||
const challenge = randomChallenge();
|
|
||||||
const challengeId = generateUUID();
|
|
||||||
await storage.createPasskeyChallenge(challengeId, userId, challenge, 'register', Date.now() + CHALLENGE_TTL_MS);
|
|
||||||
|
|
||||||
return jsonResponse({
|
|
||||||
challengeId,
|
|
||||||
publicKey: {
|
|
||||||
challenge,
|
|
||||||
rp: {
|
|
||||||
id: rpIdFromUrl(request.url),
|
|
||||||
name: 'NodeWarden',
|
|
||||||
},
|
|
||||||
user: {
|
|
||||||
id: userId,
|
|
||||||
name: userId,
|
|
||||||
displayName: userId,
|
|
||||||
},
|
|
||||||
pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }],
|
|
||||||
authenticatorSelection: {
|
|
||||||
residentKey: 'required',
|
|
||||||
userVerification: 'preferred',
|
|
||||||
},
|
|
||||||
timeout: 60000,
|
|
||||||
attestation: 'none',
|
|
||||||
excludeCredentials: passkeys.map((pk) => ({ type: 'public-key', id: pk.credentialId })),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleFinishPasskeyRegistration(request: Request, env: Env, userId: string): Promise<Response> {
|
|
||||||
const storage = new StorageService(env.DB);
|
|
||||||
const body = (await request.json()) as {
|
|
||||||
challengeId?: string;
|
|
||||||
name?: string;
|
|
||||||
wrappedVaultKeys?: string;
|
|
||||||
credential?: {
|
|
||||||
id?: string;
|
|
||||||
response?: {
|
|
||||||
clientDataJSON?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const challengeId = String(body.challengeId || '').trim();
|
|
||||||
const name = String(body.name || '').trim();
|
|
||||||
const wrappedVaultKeys = String(body.wrappedVaultKeys || '').trim();
|
|
||||||
const credentialId = String(body.credential?.id || '').trim();
|
|
||||||
const clientData = String(body.credential?.response?.clientDataJSON || '').trim();
|
|
||||||
|
|
||||||
if (!challengeId || !name || !wrappedVaultKeys || !credentialId || !clientData) {
|
|
||||||
return errorResponse('Invalid request payload', 400);
|
|
||||||
}
|
|
||||||
const challengeRecord = await storage.consumePasskeyChallenge(challengeId, 'register');
|
|
||||||
if (!challengeRecord || challengeRecord.userId !== userId) return errorResponse('Challenge expired', 400);
|
|
||||||
|
|
||||||
const parsedClientData = parseClientDataJSON(clientData);
|
|
||||||
const origin = new URL(request.url).origin;
|
|
||||||
if (!parsedClientData || parsedClientData.type !== 'webauthn.create' || parsedClientData.challenge !== challengeRecord.challenge || parsedClientData.origin !== origin) {
|
|
||||||
return errorResponse('Passkey attestation invalid', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await storage.getPasskeyByCredentialId(credentialId);
|
|
||||||
if (existing) return errorResponse('Passkey already registered', 409);
|
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const record: PasskeyCredential = {
|
|
||||||
id: generateUUID(),
|
|
||||||
userId,
|
|
||||||
credentialId,
|
|
||||||
publicKey: 'client-asserted',
|
|
||||||
counter: 0,
|
|
||||||
transports: null,
|
|
||||||
name: name.slice(0, 100),
|
|
||||||
wrappedVaultKeys,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
lastUsedAt: null,
|
|
||||||
};
|
|
||||||
await storage.createPasskey(record);
|
|
||||||
return jsonResponse({ success: true, id: record.id, object: 'passkeyCredential' });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleRenamePasskey(request: Request, env: Env, userId: string, passkeyId: string): Promise<Response> {
|
|
||||||
const body = (await request.json()) as { name?: string };
|
|
||||||
const name = String(body.name || '').trim();
|
|
||||||
if (!name) return errorResponse('Name is required', 400);
|
|
||||||
const storage = new StorageService(env.DB);
|
|
||||||
const ok = await storage.updatePasskeyName(userId, passkeyId, name.slice(0, 100));
|
|
||||||
if (!ok) return errorResponse('Passkey not found', 404);
|
|
||||||
return jsonResponse({ success: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleDeletePasskey(_request: Request, env: Env, userId: string, passkeyId: string): Promise<Response> {
|
|
||||||
const storage = new StorageService(env.DB);
|
|
||||||
const ok = await storage.deletePasskey(userId, passkeyId);
|
|
||||||
if (!ok) return errorResponse('Passkey not found', 404);
|
|
||||||
return new Response(null, { status: 204 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleBeginPasskeyLogin(request: Request, env: Env): Promise<Response> {
|
|
||||||
const storage = new StorageService(env.DB);
|
|
||||||
const body = (await request.json().catch(() => ({}))) as { email?: string };
|
|
||||||
const email = String(body.email || '').trim().toLowerCase();
|
|
||||||
const user = email ? await storage.getUser(email) : null;
|
|
||||||
const passkeys = user ? await storage.listPasskeysByUserId(user.id) : [];
|
|
||||||
|
|
||||||
const challenge = randomChallenge();
|
|
||||||
const challengeId = generateUUID();
|
|
||||||
await storage.createPasskeyChallenge(challengeId, user?.id || null, challenge, 'login', Date.now() + CHALLENGE_TTL_MS);
|
|
||||||
|
|
||||||
return jsonResponse({
|
|
||||||
challengeId,
|
|
||||||
publicKey: {
|
|
||||||
challenge,
|
|
||||||
rpId: rpIdFromUrl(request.url),
|
|
||||||
timeout: 60000,
|
|
||||||
userVerification: 'preferred',
|
|
||||||
allowCredentials: passkeys.map((pk) => ({ type: 'public-key', id: pk.credentialId })),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleFinishPasskeyLogin(request: Request, env: Env): Promise<Response> {
|
|
||||||
const storage = new StorageService(env.DB);
|
|
||||||
const auth = new AuthService(env);
|
|
||||||
const body = (await request.json()) as {
|
|
||||||
challengeId?: string;
|
|
||||||
twoFactorToken?: string;
|
|
||||||
credential?: {
|
|
||||||
id?: string;
|
|
||||||
response?: {
|
|
||||||
clientDataJSON?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
deviceIdentifier?: string;
|
|
||||||
deviceName?: string;
|
|
||||||
deviceType?: string;
|
|
||||||
};
|
|
||||||
const challengeId = String(body.challengeId || '').trim();
|
|
||||||
const credentialId = String(body.credential?.id || '').trim();
|
|
||||||
const clientData = String(body.credential?.response?.clientDataJSON || '').trim();
|
|
||||||
if (!challengeId || !credentialId || !clientData) return identityErrorResponse('Invalid request payload', 'invalid_request', 400);
|
|
||||||
|
|
||||||
const challengeRecord = await storage.consumePasskeyChallenge(challengeId, 'login');
|
|
||||||
if (!challengeRecord) return identityErrorResponse('Passkey challenge expired', 'invalid_grant', 400);
|
|
||||||
|
|
||||||
const parsedClientData = parseClientDataJSON(clientData);
|
|
||||||
const origin = new URL(request.url).origin;
|
|
||||||
if (!parsedClientData || parsedClientData.type !== 'webauthn.get' || parsedClientData.challenge !== challengeRecord.challenge || parsedClientData.origin !== origin) {
|
|
||||||
return identityErrorResponse('Passkey assertion invalid', 'invalid_grant', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const credential = await storage.getPasskeyByCredentialId(credentialId);
|
|
||||||
if (!credential) return identityErrorResponse('Passkey not recognized', 'invalid_grant', 400);
|
|
||||||
const user = await storage.getUserById(credential.userId);
|
|
||||||
if (!user || user.status !== 'active') return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
|
|
||||||
|
|
||||||
if (user.totpSecret && isTotpEnabled(user.totpSecret)) {
|
|
||||||
const token = String(body.twoFactorToken || '').trim();
|
|
||||||
if (!token) return twoFactorRequiredResponse();
|
|
||||||
const totpOk = await verifyTotpToken(user.totpSecret, token);
|
|
||||||
if (!totpOk) return identityErrorResponse('Two-step token is invalid. Try again.', 'invalid_grant', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const deviceInfo = readAuthRequestDeviceInfo(body as Record<string, string>, request);
|
|
||||||
const deviceSession = deviceInfo.deviceIdentifier ? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() } : null;
|
|
||||||
if (deviceSession) {
|
|
||||||
await storage.upsertDevice(user.id, deviceSession.identifier, deviceInfo.deviceName, deviceInfo.deviceType, deviceSession.sessionStamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
|
||||||
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
|
||||||
await storage.touchPasskeyUsage(credential.id);
|
|
||||||
|
|
||||||
let vaultKeys: { symEncKey: string; symMacKey: string } | undefined;
|
|
||||||
try {
|
|
||||||
const wrapped = JSON.parse(credential.wrappedVaultKeys) as { symEncKey?: string; symMacKey?: string };
|
|
||||||
if (wrapped.symEncKey && wrapped.symMacKey) {
|
|
||||||
vaultKeys = { symEncKey: wrapped.symEncKey, symMacKey: wrapped.symMacKey };
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
vaultKeys = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response: TokenResponse = {
|
|
||||||
access_token: accessToken,
|
|
||||||
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
|
||||||
token_type: 'Bearer',
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
Key: user.key,
|
|
||||||
PrivateKey: user.privateKey,
|
|
||||||
AccountKeys: buildAccountKeys(user),
|
|
||||||
accountKeys: buildAccountKeys(user),
|
|
||||||
Kdf: user.kdfType,
|
|
||||||
KdfIterations: user.kdfIterations,
|
|
||||||
KdfMemory: user.kdfMemory,
|
|
||||||
KdfParallelism: user.kdfParallelism,
|
|
||||||
ForcePasswordReset: false,
|
|
||||||
ResetMasterPassword: false,
|
|
||||||
MasterPasswordPolicy: { Object: 'masterPasswordPolicy' },
|
|
||||||
ApiUseKeyConnector: false,
|
|
||||||
scope: 'api offline_access',
|
|
||||||
unofficialServer: true,
|
|
||||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
|
||||||
userDecryptionOptions: buildUserDecryptionOptions(user),
|
|
||||||
VaultKeys: vaultKeys,
|
|
||||||
};
|
|
||||||
|
|
||||||
return jsonResponse(response);
|
|
||||||
}
|
|
||||||
@@ -99,12 +99,6 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
||||||
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
||||||
const userAgent = String(request.headers.get('user-agent') || '').toLowerCase();
|
|
||||||
const omitFido2Credentials =
|
|
||||||
userAgent.includes('android') ||
|
|
||||||
userAgent.includes('iphone') ||
|
|
||||||
userAgent.includes('ipad') ||
|
|
||||||
userAgent.includes('ios');
|
|
||||||
|
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -156,7 +150,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
const cipherResponses: CipherResponse[] = [];
|
const cipherResponses: CipherResponse[] = [];
|
||||||
for (const cipher of ciphers) {
|
for (const cipher of ciphers) {
|
||||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||||
cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials }));
|
cipherResponses.push(cipherToResponse(cipher, attachments));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build folder responses
|
// Build folder responses
|
||||||
|
|||||||
@@ -62,13 +62,6 @@ import {
|
|||||||
} from './handlers/attachments';
|
} from './handlers/attachments';
|
||||||
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
||||||
import { handleAdminRoute } from './router-admin';
|
import { handleAdminRoute } from './router-admin';
|
||||||
import {
|
|
||||||
handleBeginPasskeyRegistration,
|
|
||||||
handleDeletePasskey,
|
|
||||||
handleFinishPasskeyRegistration,
|
|
||||||
handleListPasskeys,
|
|
||||||
handleRenamePasskey,
|
|
||||||
} from './handlers/passkeys';
|
|
||||||
|
|
||||||
export async function handleAuthenticatedRoute(
|
export async function handleAuthenticatedRoute(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -114,24 +107,6 @@ export async function handleAuthenticatedRoute(
|
|||||||
return handleGetTotpRecoveryCode(request, env, userId);
|
return handleGetTotpRecoveryCode(request, env, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/accounts/passkeys' && method === 'GET') {
|
|
||||||
return handleListPasskeys(request, env, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/accounts/passkeys/begin-registration' && method === 'POST') {
|
|
||||||
return handleBeginPasskeyRegistration(request, env, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/accounts/passkeys/finish-registration' && method === 'POST') {
|
|
||||||
return handleFinishPasskeyRegistration(request, env, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const passkeyMatch = path.match(/^\/api\/accounts\/passkeys\/([a-f0-9-]+)$/i);
|
|
||||||
if (passkeyMatch) {
|
|
||||||
if (method === 'PATCH' || method === 'PUT') return handleRenamePasskey(request, env, userId, passkeyMatch[1]);
|
|
||||||
if (method === 'DELETE') return handleDeletePasskey(request, env, userId, passkeyMatch[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/accounts/revision-date' && method === 'GET') {
|
if (path === '/api/accounts/revision-date' && method === 'GET') {
|
||||||
return handleGetRevisionDate(request, env, userId);
|
return handleGetRevisionDate(request, env, userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
} from './handlers/sends';
|
} from './handlers/sends';
|
||||||
import { handleKnownDevice } from './handlers/devices';
|
import { handleKnownDevice } from './handlers/devices';
|
||||||
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
|
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
|
||||||
import { handleBeginPasskeyLogin, handleFinishPasskeyLogin } from './handlers/passkeys';
|
|
||||||
import {
|
import {
|
||||||
handleRegister,
|
handleRegister,
|
||||||
handleGetPasswordHint,
|
handleGetPasswordHint,
|
||||||
@@ -275,14 +274,6 @@ export async function handlePublicRoute(
|
|||||||
return handleToken(request, env);
|
return handleToken(request, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/identity/passkeys/begin-login' && method === 'POST') {
|
|
||||||
return handleBeginPasskeyLogin(request, env);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/identity/passkeys/finish-login' && method === 'POST') {
|
|
||||||
return handleFinishPasskeyLogin(request, env);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/devices/knowndevice' && method === 'GET') {
|
if (path === '/api/devices/knowndevice' && method === 'GET') {
|
||||||
const blocked = await enforcePublicRateLimit();
|
const blocked = await enforcePublicRateLimit();
|
||||||
if (blocked) return jsonResponse(false);
|
if (blocked) return jsonResponse(false);
|
||||||
|
|||||||
@@ -98,16 +98,6 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' +
|
'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' +
|
||||||
'jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)',
|
'jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS passkey_credentials (' +
|
|
||||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, credential_id TEXT NOT NULL UNIQUE, public_key TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, transports TEXT, name TEXT NOT NULL, wrapped_vault_keys TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_used_at TEXT, ' +
|
|
||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_passkey_credentials_user ON passkey_credentials(user_id)',
|
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS passkey_challenges (' +
|
|
||||||
'id TEXT PRIMARY KEY, user_id TEXT, challenge TEXT NOT NULL, action TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at TEXT NOT NULL, ' +
|
|
||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_passkey_challenges_expiry ON passkey_challenges(expires_at)',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
async function executeSchemaStatement(db: D1Database, statement: string): Promise<void> {
|
async function executeSchemaStatement(db: D1Database, statement: string): Promise<void> {
|
||||||
|
|||||||
+1
-85
@@ -1,4 +1,4 @@
|
|||||||
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, PasskeyCredential } from '../types';
|
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord } from '../types';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import { ensureStorageSchema } from './storage-schema';
|
import { ensureStorageSchema } from './storage-schema';
|
||||||
import {
|
import {
|
||||||
@@ -590,90 +590,6 @@ export class StorageService {
|
|||||||
return findStoredTrustedTokenUserId(this.db, this.trustedTwoFactorTokenKey.bind(this), token, deviceIdentifier);
|
return findStoredTrustedTokenUserId(this.db, this.trustedTwoFactorTokenKey.bind(this), token, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Passkeys ---
|
|
||||||
|
|
||||||
async createPasskeyChallenge(id: string, userId: string | null, challenge: string, action: 'register' | 'login', expiresAt: number): Promise<void> {
|
|
||||||
await this.db
|
|
||||||
.prepare('INSERT OR REPLACE INTO passkey_challenges(id, user_id, challenge, action, expires_at, created_at) VALUES(?, ?, ?, ?, ?, ?)')
|
|
||||||
.bind(id, userId, challenge, action, expiresAt, new Date().toISOString())
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
|
|
||||||
async consumePasskeyChallenge(id: string, action: 'register' | 'login'): Promise<{ challenge: string; userId: string | null } | null> {
|
|
||||||
const now = Date.now();
|
|
||||||
const row = await this.db
|
|
||||||
.prepare('SELECT challenge, user_id as userId FROM passkey_challenges WHERE id = ? AND action = ? AND expires_at > ?')
|
|
||||||
.bind(id, action, now)
|
|
||||||
.first<{ challenge: string; userId: string | null }>();
|
|
||||||
await this.db.prepare('DELETE FROM passkey_challenges WHERE id = ?').bind(id).run();
|
|
||||||
return row || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async listPasskeysByUserId(userId: string): Promise<PasskeyCredential[]> {
|
|
||||||
const rows = await this.db
|
|
||||||
.prepare('SELECT id, user_id, credential_id, public_key, counter, transports, name, wrapped_vault_keys, created_at, updated_at, last_used_at FROM passkey_credentials WHERE user_id = ? ORDER BY created_at ASC')
|
|
||||||
.bind(userId)
|
|
||||||
.all<Record<string, unknown>>();
|
|
||||||
return (rows.results || []).map((row) => ({
|
|
||||||
id: String(row.id),
|
|
||||||
userId: String(row.user_id),
|
|
||||||
credentialId: String(row.credential_id),
|
|
||||||
publicKey: String(row.public_key),
|
|
||||||
counter: Number(row.counter || 0),
|
|
||||||
transports: row.transports == null ? null : String(row.transports),
|
|
||||||
name: String(row.name || ''),
|
|
||||||
wrappedVaultKeys: String(row.wrapped_vault_keys || ''),
|
|
||||||
createdAt: String(row.created_at || ''),
|
|
||||||
updatedAt: String(row.updated_at || ''),
|
|
||||||
lastUsedAt: row.last_used_at == null ? null : String(row.last_used_at),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async getPasskeyByCredentialId(credentialId: string): Promise<PasskeyCredential | null> {
|
|
||||||
const row = await this.db
|
|
||||||
.prepare('SELECT id, user_id, credential_id, public_key, counter, transports, name, wrapped_vault_keys, created_at, updated_at, last_used_at FROM passkey_credentials WHERE credential_id = ?')
|
|
||||||
.bind(credentialId)
|
|
||||||
.first<Record<string, unknown>>();
|
|
||||||
if (!row) return null;
|
|
||||||
return {
|
|
||||||
id: String(row.id),
|
|
||||||
userId: String(row.user_id),
|
|
||||||
credentialId: String(row.credential_id),
|
|
||||||
publicKey: String(row.public_key || ''),
|
|
||||||
counter: Number(row.counter || 0),
|
|
||||||
transports: row.transports == null ? null : String(row.transports),
|
|
||||||
name: String(row.name || ''),
|
|
||||||
wrappedVaultKeys: String(row.wrapped_vault_keys || ''),
|
|
||||||
createdAt: String(row.created_at || ''),
|
|
||||||
updatedAt: String(row.updated_at || ''),
|
|
||||||
lastUsedAt: row.last_used_at == null ? null : String(row.last_used_at),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async createPasskey(record: PasskeyCredential): Promise<void> {
|
|
||||||
await this.db
|
|
||||||
.prepare('INSERT INTO passkey_credentials(id, user_id, credential_id, public_key, counter, transports, name, wrapped_vault_keys, created_at, updated_at, last_used_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
||||||
.bind(record.id, record.userId, record.credentialId, record.publicKey, record.counter, record.transports, record.name, record.wrappedVaultKeys, record.createdAt, record.updatedAt, record.lastUsedAt)
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
|
|
||||||
async updatePasskeyName(userId: string, id: string, name: string): Promise<boolean> {
|
|
||||||
const result = await this.db
|
|
||||||
.prepare('UPDATE passkey_credentials SET name = ?, updated_at = ? WHERE id = ? AND user_id = ?')
|
|
||||||
.bind(name, new Date().toISOString(), id, userId)
|
|
||||||
.run();
|
|
||||||
return (result.meta?.changes || 0) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deletePasskey(userId: string, id: string): Promise<boolean> {
|
|
||||||
const result = await this.db.prepare('DELETE FROM passkey_credentials WHERE id = ? AND user_id = ?').bind(id, userId).run();
|
|
||||||
return (result.meta?.changes || 0) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async touchPasskeyUsage(id: string): Promise<void> {
|
|
||||||
await this.db.prepare('UPDATE passkey_credentials SET last_used_at = ?, updated_at = ? WHERE id = ?').bind(new Date().toISOString(), new Date().toISOString(), id).run();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Revision dates ---
|
// --- Revision dates ---
|
||||||
|
|
||||||
async getRevisionDate(userId: string): Promise<string> {
|
async getRevisionDate(userId: string): Promise<string> {
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ export interface CipherLogin {
|
|||||||
uris: CipherLoginUri[] | null;
|
uris: CipherLoginUri[] | null;
|
||||||
totp: string | null;
|
totp: string | null;
|
||||||
autofillOnPageLoad: boolean | null;
|
autofillOnPageLoad: boolean | null;
|
||||||
fido2Credentials: any[] | null;
|
|
||||||
uri: string | null;
|
uri: string | null;
|
||||||
passwordRevisionDate: string | null;
|
passwordRevisionDate: string | null;
|
||||||
}
|
}
|
||||||
@@ -373,20 +372,6 @@ export interface TokenResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PasskeyCredential {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
credentialId: string;
|
|
||||||
publicKey: string;
|
|
||||||
counter: number;
|
|
||||||
transports: string | null;
|
|
||||||
name: string;
|
|
||||||
wrappedVaultKeys: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
lastUsedAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProfileResponse {
|
export interface ProfileResponse {
|
||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
|||||||
+3
-80
@@ -12,10 +12,6 @@ import {
|
|||||||
getAuthorizedDevices,
|
getAuthorizedDevices,
|
||||||
getCurrentDeviceIdentifier,
|
getCurrentDeviceIdentifier,
|
||||||
getPasswordHint,
|
getPasswordHint,
|
||||||
listAccountPasskeys,
|
|
||||||
registerAccountPasskey,
|
|
||||||
renameAccountPasskey,
|
|
||||||
deleteAccountPasskey,
|
|
||||||
getTotpStatus,
|
getTotpStatus,
|
||||||
saveSession,
|
saveSession,
|
||||||
} from '@/lib/api/auth';
|
} from '@/lib/api/auth';
|
||||||
@@ -40,7 +36,6 @@ import {
|
|||||||
type CompletedLogin,
|
type CompletedLogin,
|
||||||
readInitialAppBootstrapState,
|
readInitialAppBootstrapState,
|
||||||
performPasswordLogin,
|
performPasswordLogin,
|
||||||
performPasskeyLogin,
|
|
||||||
performRecoverTwoFactorLogin,
|
performRecoverTwoFactorLogin,
|
||||||
performRegistration,
|
performRegistration,
|
||||||
performTotpLogin,
|
performTotpLogin,
|
||||||
@@ -48,7 +43,6 @@ import {
|
|||||||
type JwtUnsafeReason,
|
type JwtUnsafeReason,
|
||||||
type PendingTotp,
|
type PendingTotp,
|
||||||
} from '@/lib/app-auth';
|
} from '@/lib/app-auth';
|
||||||
import { passkeySupported } from '@/lib/passkey';
|
|
||||||
import useAccountSecurityActions from '@/hooks/useAccountSecurityActions';
|
import useAccountSecurityActions from '@/hooks/useAccountSecurityActions';
|
||||||
import useAdminActions from '@/hooks/useAdminActions';
|
import useAdminActions from '@/hooks/useAdminActions';
|
||||||
import useBackupActions from '@/hooks/useBackupActions';
|
import useBackupActions from '@/hooks/useBackupActions';
|
||||||
@@ -159,7 +153,6 @@ export default function App() {
|
|||||||
const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode);
|
const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode);
|
||||||
const [unlockPassword, setUnlockPassword] = useState('');
|
const [unlockPassword, setUnlockPassword] = useState('');
|
||||||
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
|
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
|
||||||
const [pendingPasskeyTotp, setPendingPasskeyTotp] = useState(false);
|
|
||||||
const [totpCode, setTotpCode] = useState('');
|
const [totpCode, setTotpCode] = useState('');
|
||||||
const [rememberDevice, setRememberDevice] = useState(true);
|
const [rememberDevice, setRememberDevice] = useState(true);
|
||||||
|
|
||||||
@@ -341,7 +334,6 @@ export default function App() {
|
|||||||
setSession(login.session);
|
setSession(login.session);
|
||||||
setProfile(login.profile);
|
setProfile(login.profile);
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
setPendingPasskeyTotp(false);
|
|
||||||
setTotpCode('');
|
setTotpCode('');
|
||||||
setPhase('app');
|
setPhase('app');
|
||||||
if (location === '/' || location === '/login' || location === '/register' || location === '/lock') {
|
if (location === '/' || location === '/login' || location === '/register' || location === '/lock') {
|
||||||
@@ -387,53 +379,19 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleTotpVerify() {
|
async function handleTotpVerify() {
|
||||||
if (!pendingTotp && !pendingPasskeyTotp) return;
|
if (!pendingTotp) return;
|
||||||
if (!totpCode.trim()) {
|
if (!totpCode.trim()) {
|
||||||
pushToast('error', t('txt_please_input_totp_code'));
|
pushToast('error', t('txt_please_input_totp_code'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const login = pendingTotp
|
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
|
||||||
? await performTotpLogin(pendingTotp, totpCode, rememberDevice)
|
|
||||||
: (await (async () => {
|
|
||||||
const passkeyResult = await performPasskeyLogin(loginValues.email, totpCode);
|
|
||||||
if (passkeyResult.kind !== 'success') throw new Error(t('txt_totp_verify_failed'));
|
|
||||||
return passkeyResult.login;
|
|
||||||
})());
|
|
||||||
await finalizeLogin(login);
|
await finalizeLogin(login);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
|
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePasskeyLogin() {
|
|
||||||
if (pendingAuthAction) return;
|
|
||||||
if (!passkeySupported()) {
|
|
||||||
pushToast('error', '当前浏览器不支持 Passkey');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPendingAuthAction('login');
|
|
||||||
try {
|
|
||||||
const result = await performPasskeyLogin(loginValues.email);
|
|
||||||
if (result.kind === 'success') {
|
|
||||||
await finalizeLogin(result.login);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (result.kind === 'totp') {
|
|
||||||
setPendingPasskeyTotp(true);
|
|
||||||
setPendingTotp(null);
|
|
||||||
setTotpCode('');
|
|
||||||
setRememberDevice(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pushToast('error', result.message || t('txt_login_failed'));
|
|
||||||
} catch (error) {
|
|
||||||
pushToast('error', error instanceof Error ? error.message : t('txt_login_failed'));
|
|
||||||
} finally {
|
|
||||||
setPendingAuthAction(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRecoverTwoFactorSubmit() {
|
async function handleRecoverTwoFactorSubmit() {
|
||||||
const email = recoverValues.email.trim().toLowerCase();
|
const email = recoverValues.email.trim().toLowerCase();
|
||||||
const password = recoverValues.password;
|
const password = recoverValues.password;
|
||||||
@@ -569,24 +527,6 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreatePasskey(name: string) {
|
|
||||||
if (!session?.symEncKey || !session?.symMacKey) throw new Error('请先解锁后再创建 Passkey');
|
|
||||||
await registerAccountPasskey(authedFetch, name, session);
|
|
||||||
await passkeysQuery.refetch();
|
|
||||||
pushToast('success', 'Passkey 已创建');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRenamePasskey(id: string, name: string) {
|
|
||||||
await renameAccountPasskey(authedFetch, id, name);
|
|
||||||
await passkeysQuery.refetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeletePasskey(id: string) {
|
|
||||||
await deleteAccountPasskey(authedFetch, id);
|
|
||||||
await passkeysQuery.refetch();
|
|
||||||
pushToast('success', 'Passkey 已删除');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLock() {
|
function handleLock() {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
const nextSession = { ...session };
|
const nextSession = { ...session };
|
||||||
@@ -602,7 +542,6 @@ export default function App() {
|
|||||||
setSession(null);
|
setSession(null);
|
||||||
setProfile(null);
|
setProfile(null);
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
setPendingPasskeyTotp(false);
|
|
||||||
setPhase('login');
|
setPhase('login');
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
}
|
}
|
||||||
@@ -677,11 +616,6 @@ export default function App() {
|
|||||||
queryFn: () => getAuthorizedDevices(authedFetch),
|
queryFn: () => getAuthorizedDevices(authedFetch),
|
||||||
enabled: phase === 'app' && !!session?.accessToken,
|
enabled: phase === 'app' && !!session?.accessToken,
|
||||||
});
|
});
|
||||||
const passkeysQuery = useQuery({
|
|
||||||
queryKey: ['account-passkeys', session?.accessToken],
|
|
||||||
queryFn: () => listAccountPasskeys(authedFetch),
|
|
||||||
enabled: phase === 'app' && !!session?.accessToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return;
|
if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return;
|
||||||
@@ -757,9 +691,6 @@ export default function App() {
|
|||||||
decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac),
|
decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac),
|
||||||
decPassword: await decryptField(cipher.login.password || '', itemEnc, itemMac),
|
decPassword: await decryptField(cipher.login.password || '', itemEnc, itemMac),
|
||||||
decTotp: await decryptField(cipher.login.totp || '', itemEnc, itemMac),
|
decTotp: await decryptField(cipher.login.totp || '', itemEnc, itemMac),
|
||||||
fido2Credentials: Array.isArray(cipher.login.fido2Credentials)
|
|
||||||
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
|
|
||||||
: null,
|
|
||||||
uris: await Promise.all(
|
uris: await Promise.all(
|
||||||
(cipher.login.uris || []).map(async (u) => ({
|
(cipher.login.uris || []).map(async (u) => ({
|
||||||
...u,
|
...u,
|
||||||
@@ -1205,10 +1136,6 @@ export default function App() {
|
|||||||
},
|
},
|
||||||
onOpenDisableTotp: () => setDisableTotpOpen(true),
|
onOpenDisableTotp: () => setDisableTotpOpen(true),
|
||||||
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
|
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
|
||||||
passkeys: passkeysQuery.data || [],
|
|
||||||
onCreatePasskey: handleCreatePasskey,
|
|
||||||
onRenamePasskey: handleRenamePasskey,
|
|
||||||
onDeletePasskey: handleDeletePasskey,
|
|
||||||
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
||||||
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
||||||
onRemoveDevice: accountSecurityActions.openRemoveDevice,
|
onRemoveDevice: accountSecurityActions.openRemoveDevice,
|
||||||
@@ -1280,7 +1207,6 @@ export default function App() {
|
|||||||
onChangeRegister={setRegisterValues}
|
onChangeRegister={setRegisterValues}
|
||||||
onChangeUnlock={setUnlockPassword}
|
onChangeUnlock={setUnlockPassword}
|
||||||
onSubmitLogin={() => void handleLogin()}
|
onSubmitLogin={() => void handleLogin()}
|
||||||
onSubmitPasskey={() => void handlePasskeyLogin()}
|
|
||||||
onSubmitRegister={() => void handleRegister()}
|
onSubmitRegister={() => void handleRegister()}
|
||||||
onSubmitUnlock={() => void handleUnlock()}
|
onSubmitUnlock={() => void handleUnlock()}
|
||||||
onGotoLogin={() => {
|
onGotoLogin={() => {
|
||||||
@@ -1297,14 +1223,13 @@ export default function App() {
|
|||||||
onLogout={logoutNow}
|
onLogout={logoutNow}
|
||||||
onTogglePasswordHint={() => void handleTogglePasswordHint()}
|
onTogglePasswordHint={() => void handleTogglePasswordHint()}
|
||||||
onShowLockedPasswordHint={handleShowLockedPasswordHint}
|
onShowLockedPasswordHint={handleShowLockedPasswordHint}
|
||||||
passkeySupported={passkeySupported()}
|
|
||||||
/>
|
/>
|
||||||
<AppGlobalOverlays
|
<AppGlobalOverlays
|
||||||
toasts={toasts}
|
toasts={toasts}
|
||||||
onCloseToast={removeToast}
|
onCloseToast={removeToast}
|
||||||
confirm={confirm}
|
confirm={confirm}
|
||||||
onCancelConfirm={() => setConfirm(null)}
|
onCancelConfirm={() => setConfirm(null)}
|
||||||
pendingTotpOpen={!!pendingTotp || pendingPasskeyTotp}
|
pendingTotpOpen={!!pendingTotp}
|
||||||
totpCode={totpCode}
|
totpCode={totpCode}
|
||||||
rememberDevice={rememberDevice}
|
rememberDevice={rememberDevice}
|
||||||
onTotpCodeChange={setTotpCode}
|
onTotpCodeChange={setTotpCode}
|
||||||
@@ -1312,13 +1237,11 @@ export default function App() {
|
|||||||
onConfirmTotp={() => void handleTotpVerify()}
|
onConfirmTotp={() => void handleTotpVerify()}
|
||||||
onCancelTotp={() => {
|
onCancelTotp={() => {
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
setPendingPasskeyTotp(false);
|
|
||||||
setTotpCode('');
|
setTotpCode('');
|
||||||
setRememberDevice(true);
|
setRememberDevice(true);
|
||||||
}}
|
}}
|
||||||
onUseRecoveryCode={() => {
|
onUseRecoveryCode={() => {
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
setPendingPasskeyTotp(false);
|
|
||||||
setTotpCode('');
|
setTotpCode('');
|
||||||
setRememberDevice(true);
|
setRememberDevice(true);
|
||||||
navigate('/recover-2fa');
|
navigate('/recover-2fa');
|
||||||
|
|||||||
@@ -94,10 +94,6 @@ export interface AppMainRoutesProps {
|
|||||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||||
onOpenDisableTotp: () => void;
|
onOpenDisableTotp: () => void;
|
||||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||||
passkeys: Array<{ id: string; name: string; creationDate: string; lastUsedDate: string | null }>;
|
|
||||||
onCreatePasskey: (name: string) => Promise<void>;
|
|
||||||
onRenamePasskey: (id: string, name: string) => Promise<void>;
|
|
||||||
onDeletePasskey: (id: string) => Promise<void>;
|
|
||||||
onRefreshAuthorizedDevices: () => Promise<void>;
|
onRefreshAuthorizedDevices: () => Promise<void>;
|
||||||
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
||||||
onRemoveDevice: (device: AuthorizedDevice) => void;
|
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||||
@@ -229,10 +225,6 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
onOpenDisableTotp={props.onOpenDisableTotp}
|
onOpenDisableTotp={props.onOpenDisableTotp}
|
||||||
onGetRecoveryCode={props.onGetRecoveryCode}
|
onGetRecoveryCode={props.onGetRecoveryCode}
|
||||||
onNotify={props.onNotify}
|
onNotify={props.onNotify}
|
||||||
passkeys={props.passkeys}
|
|
||||||
onCreatePasskey={props.onCreatePasskey}
|
|
||||||
onRenamePasskey={props.onRenamePasskey}
|
|
||||||
onDeletePasskey={props.onDeletePasskey}
|
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'preact/hooks';
|
import { useState } from 'preact/hooks';
|
||||||
import { ArrowLeft, Eye, EyeOff, Fingerprint, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
|
import { ArrowLeft, Eye, EyeOff, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
|
||||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -30,7 +30,6 @@ interface AuthViewsProps {
|
|||||||
onChangeRegister: (next: RegisterValues) => void;
|
onChangeRegister: (next: RegisterValues) => void;
|
||||||
onChangeUnlock: (password: string) => void;
|
onChangeUnlock: (password: string) => void;
|
||||||
onSubmitLogin: () => void;
|
onSubmitLogin: () => void;
|
||||||
onSubmitPasskey: () => void;
|
|
||||||
onSubmitRegister: () => void;
|
onSubmitRegister: () => void;
|
||||||
onSubmitUnlock: () => void;
|
onSubmitUnlock: () => void;
|
||||||
onGotoLogin: () => void;
|
onGotoLogin: () => void;
|
||||||
@@ -38,7 +37,6 @@ interface AuthViewsProps {
|
|||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
onTogglePasswordHint: () => void;
|
onTogglePasswordHint: () => void;
|
||||||
onShowLockedPasswordHint: () => void;
|
onShowLockedPasswordHint: () => void;
|
||||||
passkeySupported: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function PasswordField(props: {
|
function PasswordField(props: {
|
||||||
@@ -108,12 +106,6 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
<Unlock size={16} className="btn-icon" />
|
<Unlock size={16} className="btn-icon" />
|
||||||
{unlockBusy ? t('txt_unlocking') : t('txt_unlock')}
|
{unlockBusy ? t('txt_unlocking') : t('txt_unlock')}
|
||||||
</button>
|
</button>
|
||||||
{props.passkeySupported && (
|
|
||||||
<button type="button" className="btn btn-secondary full" onClick={props.onSubmitPasskey} disabled={unlockBusy}>
|
|
||||||
<Fingerprint size={16} className="btn-icon" />
|
|
||||||
Passkey 解锁
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="or">{t('txt_or')}</div>
|
<div className="or">{t('txt_or')}</div>
|
||||||
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}>
|
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}>
|
||||||
<LogOut size={16} className="btn-icon" />
|
<LogOut size={16} className="btn-icon" />
|
||||||
@@ -251,12 +243,6 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
<LogIn size={16} className="btn-icon" />
|
<LogIn size={16} className="btn-icon" />
|
||||||
{loginBusy ? t('txt_logging_in') : t('txt_log_in')}
|
{loginBusy ? t('txt_logging_in') : t('txt_log_in')}
|
||||||
</button>
|
</button>
|
||||||
{props.passkeySupported && (
|
|
||||||
<button type="button" className="btn btn-secondary full" onClick={props.onSubmitPasskey} disabled={loginBusy}>
|
|
||||||
<Fingerprint size={16} className="btn-icon" />
|
|
||||||
Passkey 登录
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="or">{t('txt_or')}</div>
|
<div className="or">{t('txt_or')}</div>
|
||||||
<button type="button" className="btn btn-secondary full" onClick={props.onGotoRegister} disabled={loginBusy}>
|
<button type="button" className="btn btn-secondary full" onClick={props.onGotoRegister} disabled={loginBusy}>
|
||||||
<UserPlus size={16} className="btn-icon" />
|
<UserPlus size={16} className="btn-icon" />
|
||||||
|
|||||||
@@ -15,10 +15,6 @@ interface SettingsPageProps {
|
|||||||
onOpenDisableTotp: () => void;
|
onOpenDisableTotp: () => void;
|
||||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||||
onNotify?: (type: 'success' | 'error', text: string) => void;
|
onNotify?: (type: 'success' | 'error', text: string) => void;
|
||||||
passkeys: Array<{ id: string; name: string; creationDate: string; lastUsedDate: string | null }>;
|
|
||||||
onCreatePasskey: (name: string) => Promise<void>;
|
|
||||||
onRenamePasskey: (id: string, name: string) => Promise<void>;
|
|
||||||
onDeletePasskey: (id: string) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function randomBase32Secret(length: number): string {
|
function randomBase32Secret(length: number): string {
|
||||||
@@ -52,10 +48,6 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
|
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
|
||||||
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
|
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
|
||||||
const [recoveryCode, setRecoveryCode] = useState('');
|
const [recoveryCode, setRecoveryCode] = useState('');
|
||||||
const [passkeyName, setPasskeyName] = useState('');
|
|
||||||
const [renamePasskey, setRenamePasskey] = useState<{ id: string; name: string } | null>(null);
|
|
||||||
const [renamePasskeyName, setRenamePasskeyName] = useState('');
|
|
||||||
const [deletePasskey, setDeletePasskey] = useState<{ id: string; name: string } | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!props.totpEnabled) {
|
if (!props.totpEnabled) {
|
||||||
@@ -102,21 +94,6 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
return parsed.toLocaleString();
|
return parsed.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmRenamePasskey(): Promise<void> {
|
|
||||||
if (!renamePasskey) return;
|
|
||||||
const nextName = renamePasskeyName.trim();
|
|
||||||
if (!nextName) return;
|
|
||||||
await props.onRenamePasskey(renamePasskey.id, nextName);
|
|
||||||
setRenamePasskey(null);
|
|
||||||
setRenamePasskeyName('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmDeletePasskey(): Promise<void> {
|
|
||||||
if (!deletePasskey) return;
|
|
||||||
await props.onDeletePasskey(deletePasskey.id);
|
|
||||||
setDeletePasskey(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<section className="card">
|
<section className="card">
|
||||||
@@ -172,91 +149,6 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card">
|
|
||||||
<h3>Passkey</h3>
|
|
||||||
<div className="field-grid">
|
|
||||||
<label className="field">
|
|
||||||
<span>名称</span>
|
|
||||||
<input className="input" value={passkeyName} onInput={(e) => setPasskeyName((e.currentTarget as HTMLInputElement).value)} placeholder="例如:MacBook Touch ID" />
|
|
||||||
</label>
|
|
||||||
<div className="field" style={{ alignSelf: 'end' }}>
|
|
||||||
<button type="button" className="btn btn-primary" disabled={!passkeyName.trim()} onClick={() => void props.onCreatePasskey(passkeyName.trim()).then(() => setPasskeyName(''))}>
|
|
||||||
创建 Passkey
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="muted-inline" style={{ marginBottom: 8 }}>最多 5 个,支持重命名和删除。</p>
|
|
||||||
<div className="stack" style={{ gap: 6 }}>
|
|
||||||
{props.passkeys.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: 8,
|
|
||||||
alignItems: 'center',
|
|
||||||
border: '1px solid var(--line)',
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: '10px 12px',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>{item.name}</strong>
|
|
||||||
<span style={{ marginLeft: 'auto', fontSize: 12, opacity: 0.72 }}>
|
|
||||||
创建于 {formatDateTime(item.creationDate)}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary small"
|
|
||||||
onClick={() => {
|
|
||||||
setRenamePasskey({ id: item.id, name: item.name });
|
|
||||||
setRenamePasskeyName(item.name);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('txt_edit')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-danger small"
|
|
||||||
onClick={() => setDeletePasskey({ id: item.id, name: item.name })}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!props.passkeys.length && <div className="empty">暂无 Passkey</div>}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={!!renamePasskey}
|
|
||||||
title={t('txt_edit')}
|
|
||||||
message={t('txt_enter_a_folder_name')}
|
|
||||||
confirmText={t('txt_save')}
|
|
||||||
cancelText={t('txt_cancel')}
|
|
||||||
onConfirm={() => void confirmRenamePasskey()}
|
|
||||||
onCancel={() => {
|
|
||||||
setRenamePasskey(null);
|
|
||||||
setRenamePasskeyName('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label className="field">
|
|
||||||
<span>{t('txt_name')}</span>
|
|
||||||
<input className="input" value={renamePasskeyName} onInput={(e) => setRenamePasskeyName((e.currentTarget as HTMLInputElement).value)} />
|
|
||||||
</label>
|
|
||||||
</ConfirmDialog>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={!!deletePasskey}
|
|
||||||
title={t('txt_delete')}
|
|
||||||
message={deletePasskey ? `确认删除 Passkey「${deletePasskey.name}」吗?` : ''}
|
|
||||||
variant="warning"
|
|
||||||
danger
|
|
||||||
confirmText={t('txt_delete')}
|
|
||||||
cancelText={t('txt_cancel')}
|
|
||||||
onConfirm={() => void confirmDeletePasskey()}
|
|
||||||
onCancel={() => setDeletePasskey(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="settings-twofactor-grid">
|
<div className="settings-twofactor-grid">
|
||||||
<div className="settings-subcard">
|
<div className="settings-subcard">
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
draftFromCipher,
|
draftFromCipher,
|
||||||
buildCipherDuplicateSignature,
|
buildCipherDuplicateSignature,
|
||||||
firstCipherUri,
|
firstCipherUri,
|
||||||
firstPasskeyCreationTime,
|
|
||||||
isCipherVisibleInArchive,
|
isCipherVisibleInArchive,
|
||||||
isCipherVisibleInNormalVault,
|
isCipherVisibleInNormalVault,
|
||||||
isCipherVisibleInTrash,
|
isCipherVisibleInTrash,
|
||||||
@@ -352,7 +351,6 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
() => filteredCiphers.slice(virtualRange.start, virtualRange.end),
|
() => filteredCiphers.slice(virtualRange.start, virtualRange.end),
|
||||||
[filteredCiphers, virtualRange.start, virtualRange.end]
|
[filteredCiphers, virtualRange.start, virtualRange.end]
|
||||||
);
|
);
|
||||||
const passkeyCreatedAt = firstPasskeyCreationTime(selectedCipher);
|
|
||||||
const selectedAttachments = useMemo(
|
const selectedAttachments = useMemo(
|
||||||
() => (Array.isArray(selectedCipher?.attachments) ? selectedCipher.attachments : []),
|
() => (Array.isArray(selectedCipher?.attachments) ? selectedCipher.attachments : []),
|
||||||
[selectedCipher]
|
[selectedCipher]
|
||||||
@@ -973,7 +971,6 @@ function folderName(id: string | null | undefined): string {
|
|||||||
repromptApprovedCipherId={repromptApprovedCipherId}
|
repromptApprovedCipherId={repromptApprovedCipherId}
|
||||||
showPassword={showPassword}
|
showPassword={showPassword}
|
||||||
totpLive={totpLive}
|
totpLive={totpLive}
|
||||||
passkeyCreatedAt={passkeyCreatedAt}
|
|
||||||
hiddenFieldVisibleMap={hiddenFieldVisibleMap}
|
hiddenFieldVisibleMap={hiddenFieldVisibleMap}
|
||||||
folderName={folderName}
|
folderName={folderName}
|
||||||
onOpenReprompt={() => setRepromptOpen(true)}
|
onOpenReprompt={() => setRepromptOpen(true)}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ interface VaultDetailViewProps {
|
|||||||
repromptApprovedCipherId: string | null;
|
repromptApprovedCipherId: string | null;
|
||||||
showPassword: boolean;
|
showPassword: boolean;
|
||||||
totpLive: { code: string; remain: number } | null;
|
totpLive: { code: string; remain: number } | null;
|
||||||
passkeyCreatedAt: string | null;
|
|
||||||
hiddenFieldVisibleMap: Record<number, boolean>;
|
hiddenFieldVisibleMap: Record<number, boolean>;
|
||||||
folderName: (id: string | null | undefined) => string;
|
folderName: (id: string | null | undefined) => string;
|
||||||
downloadingAttachmentKey: string;
|
downloadingAttachmentKey: string;
|
||||||
@@ -136,15 +135,6 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!!props.passkeyCreatedAt && (
|
|
||||||
<div className="kv-row">
|
|
||||||
<span className="kv-label">{t('txt_passkey')}</span>
|
|
||||||
<div className="kv-main">
|
|
||||||
<strong>{t('txt_passkey_created_at_value', { value: formatHistoryTime(props.passkeyCreatedAt) })}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="kv-actions" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -194,9 +194,6 @@ export function buildCipherDuplicateSignature(cipher: Cipher): string {
|
|||||||
uri: valueOrFallback(uri.decUri ?? uri.uri),
|
uri: valueOrFallback(uri.decUri ?? uri.uri),
|
||||||
match: uri.match ?? null,
|
match: uri.match ?? null,
|
||||||
})),
|
})),
|
||||||
fido2Credentials: (cipher.login.fido2Credentials || []).map((credential) => ({
|
|
||||||
creationDate: valueOrFallback(credential.creationDate),
|
|
||||||
})),
|
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
card: cipher.card
|
card: cipher.card
|
||||||
@@ -265,7 +262,6 @@ export function createEmptyDraft(type: number): VaultDraft {
|
|||||||
loginPassword: '',
|
loginPassword: '',
|
||||||
loginTotp: '',
|
loginTotp: '',
|
||||||
loginUris: [createEmptyLoginUri()],
|
loginUris: [createEmptyLoginUri()],
|
||||||
loginFido2Credentials: [],
|
|
||||||
cardholderName: '',
|
cardholderName: '',
|
||||||
cardNumber: '',
|
cardNumber: '',
|
||||||
cardBrand: '',
|
cardBrand: '',
|
||||||
@@ -314,9 +310,6 @@ export function draftFromCipher(cipher: Cipher): VaultDraft {
|
|||||||
uri: x.decUri || x.uri || '',
|
uri: x.decUri || x.uri || '',
|
||||||
match: x.match ?? null,
|
match: x.match ?? null,
|
||||||
}));
|
}));
|
||||||
draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials)
|
|
||||||
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
|
|
||||||
: [];
|
|
||||||
if (!draft.loginUris.length) draft.loginUris = [createEmptyLoginUri()];
|
if (!draft.loginUris.length) draft.loginUris = [createEmptyLoginUri()];
|
||||||
}
|
}
|
||||||
if (cipher.card) {
|
if (cipher.card) {
|
||||||
@@ -413,16 +406,6 @@ export function creationTimeValue(cipher: Cipher): number {
|
|||||||
return Number.isFinite(time) ? time : 0;
|
return Number.isFinite(time) ? time : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
|
|
||||||
const credentials = cipher?.login?.fido2Credentials;
|
|
||||||
if (!Array.isArray(credentials) || credentials.length === 0) return null;
|
|
||||||
for (const credential of credentials) {
|
|
||||||
const raw = String(credential?.creationDate || '').trim();
|
|
||||||
if (raw) return raw;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const failedIconHosts = new Set<string>();
|
const failedIconHosts = new Set<string>();
|
||||||
|
|
||||||
export function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
export function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import type {
|
|||||||
TokenSuccess,
|
TokenSuccess,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
||||||
import { createPasskeyCredential, requestPasskeyAssertion } from '../passkey';
|
|
||||||
|
|
||||||
const SESSION_KEY = 'nodewarden.web.session.v4';
|
const SESSION_KEY = 'nodewarden.web.session.v4';
|
||||||
const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1';
|
const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1';
|
||||||
@@ -27,14 +26,6 @@ export interface PreloginKdfConfig {
|
|||||||
kdfParallelism: number | null;
|
kdfParallelism: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccountPasskey {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
creationDate: string;
|
|
||||||
revisionDate: string;
|
|
||||||
lastUsedDate: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function randomHex(length: number): string {
|
function randomHex(length: number): string {
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
|
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
|
||||||
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
|
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
|
||||||
@@ -206,84 +197,6 @@ export async function refreshAccessToken(refreshToken: string): Promise<TokenSuc
|
|||||||
return json || null;
|
return json || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAccountPasskeys(authedFetch: AuthedFetch): Promise<AccountPasskey[]> {
|
|
||||||
const resp = await authedFetch('/api/accounts/passkeys');
|
|
||||||
if (!resp.ok) throw new Error('Failed to load passkeys');
|
|
||||||
const body = (await parseJson<{ data?: AccountPasskey[] }>(resp)) || {};
|
|
||||||
return Array.isArray(body.data) ? body.data : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function registerAccountPasskey(authedFetch: AuthedFetch, name: string, session: SessionState): Promise<void> {
|
|
||||||
const beginResp = await authedFetch('/api/accounts/passkeys/begin-registration', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: '{}',
|
|
||||||
});
|
|
||||||
if (!beginResp.ok) throw new Error('Failed to start passkey registration');
|
|
||||||
const begin = (await parseJson<{ challengeId: string; publicKey: Record<string, any> }>(beginResp)) || {};
|
|
||||||
if (!begin.challengeId || !begin.publicKey) throw new Error('Invalid registration challenge');
|
|
||||||
|
|
||||||
const credential = await createPasskeyCredential(begin.publicKey);
|
|
||||||
const finishResp = await authedFetch('/api/accounts/passkeys/finish-registration', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
challengeId: begin.challengeId,
|
|
||||||
name,
|
|
||||||
wrappedVaultKeys: JSON.stringify({
|
|
||||||
symEncKey: session.symEncKey || '',
|
|
||||||
symMacKey: session.symMacKey || '',
|
|
||||||
}),
|
|
||||||
credential,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!finishResp.ok) {
|
|
||||||
const err = await parseJson<TokenError>(finishResp);
|
|
||||||
throw new Error(err?.error_description || err?.error || 'Failed to finish passkey registration');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function renameAccountPasskey(authedFetch: AuthedFetch, passkeyId: string, name: string): Promise<void> {
|
|
||||||
const resp = await authedFetch(`/api/accounts/passkeys/${passkeyId}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name }),
|
|
||||||
});
|
|
||||||
if (!resp.ok) throw new Error('Failed to rename passkey');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteAccountPasskey(authedFetch: AuthedFetch, passkeyId: string): Promise<void> {
|
|
||||||
const resp = await authedFetch(`/api/accounts/passkeys/${passkeyId}`, { method: 'DELETE' });
|
|
||||||
if (!resp.ok && resp.status !== 204) throw new Error('Failed to delete passkey');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loginWithPasskey(email?: string, totpCode?: string): Promise<TokenSuccess | TokenError> {
|
|
||||||
const beginResp = await fetch('/identity/passkeys/begin-login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ email: String(email || '').trim().toLowerCase() || undefined }),
|
|
||||||
});
|
|
||||||
if (!beginResp.ok) return ((await parseJson<TokenError>(beginResp)) || {});
|
|
||||||
const begin = (await parseJson<{ challengeId: string; publicKey: Record<string, any> }>(beginResp)) || {};
|
|
||||||
if (!begin.challengeId || !begin.publicKey) return { error: 'Passkey challenge missing' };
|
|
||||||
|
|
||||||
const credential = await requestPasskeyAssertion(begin.publicKey);
|
|
||||||
const finishResp = await fetch('/identity/passkeys/finish-login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
challengeId: begin.challengeId,
|
|
||||||
credential,
|
|
||||||
deviceIdentifier: getOrCreateDeviceIdentifier(),
|
|
||||||
deviceName: guessDeviceName(),
|
|
||||||
deviceType: '14',
|
|
||||||
twoFactorToken: totpCode || undefined,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const result = (await parseJson<TokenSuccess & TokenError>(finishResp)) || {};
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function registerAccount(args: {
|
export async function registerAccount(args: {
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -392,56 +392,6 @@ function toIsoDateOrNow(value: unknown): string {
|
|||||||
return parsed.toISOString();
|
return parsed.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function encryptMaybeFidoValue(
|
|
||||||
value: unknown,
|
|
||||||
enc: Uint8Array,
|
|
||||||
mac: Uint8Array,
|
|
||||||
fallback = ''
|
|
||||||
): Promise<string> {
|
|
||||||
const normalized = String(value ?? '').trim() || fallback;
|
|
||||||
if (looksLikeCipherString(normalized)) return normalized;
|
|
||||||
return encryptBw(new TextEncoder().encode(normalized), enc, mac);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function encryptMaybeNullableFidoValue(
|
|
||||||
value: unknown,
|
|
||||||
enc: Uint8Array,
|
|
||||||
mac: Uint8Array
|
|
||||||
): Promise<string | null> {
|
|
||||||
const normalized = String(value ?? '').trim();
|
|
||||||
if (!normalized) return null;
|
|
||||||
if (looksLikeCipherString(normalized)) return normalized;
|
|
||||||
return encryptBw(new TextEncoder().encode(normalized), enc, mac);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function normalizeFido2Credentials(
|
|
||||||
credentials: Array<Record<string, unknown>> | null | undefined,
|
|
||||||
enc: Uint8Array,
|
|
||||||
mac: Uint8Array
|
|
||||||
): Promise<Array<Record<string, unknown>> | null> {
|
|
||||||
if (!Array.isArray(credentials) || credentials.length === 0) return null;
|
|
||||||
const out: Array<Record<string, unknown>> = [];
|
|
||||||
for (const credential of credentials) {
|
|
||||||
if (!credential || typeof credential !== 'object') continue;
|
|
||||||
out.push({
|
|
||||||
credentialId: await encryptMaybeFidoValue(credential.credentialId, enc, mac),
|
|
||||||
keyType: await encryptMaybeFidoValue(credential.keyType, enc, mac, 'public-key'),
|
|
||||||
keyAlgorithm: await encryptMaybeFidoValue(credential.keyAlgorithm, enc, mac, 'ECDSA'),
|
|
||||||
keyCurve: await encryptMaybeFidoValue(credential.keyCurve, enc, mac, 'P-256'),
|
|
||||||
keyValue: await encryptMaybeFidoValue(credential.keyValue, enc, mac),
|
|
||||||
rpId: await encryptMaybeFidoValue(credential.rpId, enc, mac),
|
|
||||||
rpName: await encryptMaybeNullableFidoValue(credential.rpName, enc, mac),
|
|
||||||
userHandle: await encryptMaybeNullableFidoValue(credential.userHandle, enc, mac),
|
|
||||||
userName: await encryptMaybeNullableFidoValue(credential.userName, enc, mac),
|
|
||||||
userDisplayName: await encryptMaybeNullableFidoValue(credential.userDisplayName, enc, mac),
|
|
||||||
counter: await encryptMaybeFidoValue(credential.counter, enc, mac, '0'),
|
|
||||||
discoverable: await encryptMaybeFidoValue(credential.discoverable, enc, mac, 'false'),
|
|
||||||
creationDate: toIsoDateOrNow(credential.creationDate),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return out.length ? out : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCipherKeys(
|
async function getCipherKeys(
|
||||||
cipher: Cipher | null,
|
cipher: Cipher | null,
|
||||||
userEnc: Uint8Array,
|
userEnc: Uint8Array,
|
||||||
@@ -490,15 +440,10 @@ async function buildCipherPayload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === 1) {
|
if (type === 1) {
|
||||||
const existingFido2 =
|
|
||||||
cipher?.login && Array.isArray((cipher.login as any).fido2Credentials)
|
|
||||||
? (cipher.login as any).fido2Credentials
|
|
||||||
: draft.loginFido2Credentials;
|
|
||||||
payload.login = {
|
payload.login = {
|
||||||
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
|
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
|
||||||
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
|
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
|
||||||
totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac),
|
totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac),
|
||||||
fido2Credentials: await normalizeFido2Credentials(existingFido2, keys.enc, keys.mac),
|
|
||||||
uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac),
|
uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac),
|
||||||
};
|
};
|
||||||
} else if (type === 3) {
|
} else if (type === 3) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
getProfile,
|
getProfile,
|
||||||
loadSession,
|
loadSession,
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
loginWithPasskey,
|
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
recoverTwoFactor,
|
recoverTwoFactor,
|
||||||
registerAccount,
|
registerAccount,
|
||||||
@@ -47,11 +46,6 @@ export type PasswordLoginResult =
|
|||||||
| { kind: 'totp'; pendingTotp: PendingTotp }
|
| { kind: 'totp'; pendingTotp: PendingTotp }
|
||||||
| { kind: 'error'; message: string };
|
| { kind: 'error'; message: string };
|
||||||
|
|
||||||
export type PasskeyLoginResult =
|
|
||||||
| { kind: 'success'; login: CompletedLogin }
|
|
||||||
| { kind: 'totp' }
|
|
||||||
| { kind: 'error'; message: string };
|
|
||||||
|
|
||||||
export interface RecoverTwoFactorResult {
|
export interface RecoverTwoFactorResult {
|
||||||
login: CompletedLogin | null;
|
login: CompletedLogin | null;
|
||||||
newRecoveryCode: string | null;
|
newRecoveryCode: string | null;
|
||||||
@@ -366,29 +360,3 @@ export async function performUnlock(
|
|||||||
return { ...refreshedSession, ...keys };
|
return { ...refreshedSession, ...keys };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function performPasskeyLogin(email: string, totpCode?: string): Promise<PasskeyLoginResult> {
|
|
||||||
const token = await loginWithPasskey(email, totpCode);
|
|
||||||
if ('access_token' in token && token.access_token) {
|
|
||||||
const normalizedEmail = String(email || '').trim().toLowerCase();
|
|
||||||
const baseSession: SessionState = {
|
|
||||||
accessToken: token.access_token,
|
|
||||||
refreshToken: token.refresh_token,
|
|
||||||
email: normalizedEmail,
|
|
||||||
symEncKey: token.VaultKeys?.symEncKey,
|
|
||||||
symMacKey: token.VaultKeys?.symMacKey,
|
|
||||||
};
|
|
||||||
const tempFetch = createAuthedFetch(() => baseSession, () => {});
|
|
||||||
const profile = buildTransientProfile(token, normalizedEmail);
|
|
||||||
return {
|
|
||||||
kind: 'success',
|
|
||||||
login: {
|
|
||||||
session: baseSession,
|
|
||||||
profile,
|
|
||||||
profilePromise: getProfile(tempFetch),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
|
||||||
if (tokenError.TwoFactorProviders) return { kind: 'totp' };
|
|
||||||
return { kind: 'error', message: tokenError.error_description || tokenError.error || 'Passkey login failed' };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ export function buildEmptyImportDraft(type: number): VaultDraft {
|
|||||||
loginPassword: '',
|
loginPassword: '',
|
||||||
loginTotp: '',
|
loginTotp: '',
|
||||||
loginUris: [{ uri: '', match: null }],
|
loginUris: [{ uri: '', match: null }],
|
||||||
loginFido2Credentials: [],
|
|
||||||
cardholderName: '',
|
cardholderName: '',
|
||||||
cardNumber: '',
|
cardNumber: '',
|
||||||
cardBrand: '',
|
cardBrand: '',
|
||||||
@@ -161,11 +160,6 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
|
|||||||
draft.loginUsername = asText(login.username);
|
draft.loginUsername = asText(login.username);
|
||||||
draft.loginPassword = asText(login.password);
|
draft.loginPassword = asText(login.password);
|
||||||
draft.loginTotp = asText(login.totp);
|
draft.loginTotp = asText(login.totp);
|
||||||
draft.loginFido2Credentials = Array.isArray(login.fido2Credentials)
|
|
||||||
? login.fido2Credentials
|
|
||||||
.filter((credential): credential is Record<string, unknown> => !!credential && typeof credential === 'object')
|
|
||||||
.map((credential) => ({ ...credential }))
|
|
||||||
: [];
|
|
||||||
const urisRaw = Array.isArray(login.uris) ? login.uris : [];
|
const urisRaw = Array.isArray(login.uris) ? login.uris : [];
|
||||||
const uris = urisRaw
|
const uris = urisRaw
|
||||||
.map((u) => {
|
.map((u) => {
|
||||||
|
|||||||
@@ -198,7 +198,6 @@ function mapCipherEncrypted(cipher: Cipher): Record<string, unknown> {
|
|||||||
match: (uri as { match?: unknown })?.match ?? null,
|
match: (uri as { match?: unknown })?.match ?? null,
|
||||||
}))
|
}))
|
||||||
: [],
|
: [],
|
||||||
fido2Credentials: Array.isArray(login.fido2Credentials) ? cloneValue(login.fido2Credentials) : [],
|
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -292,9 +291,6 @@ async function mapCipherPlain(cipher: Cipher, userEnc: Uint8Array, userMac: Uint
|
|||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
fido2Credentials: Array.isArray(cipher.login.fido2Credentials)
|
|
||||||
? await Promise.all(cipher.login.fido2Credentials.map((credential) => deepDecryptUnknown(credential, keyParts.enc, keyParts.mac)))
|
|
||||||
: [],
|
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
out.login = null;
|
out.login = null;
|
||||||
|
|||||||
@@ -671,8 +671,6 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_total_items_count: "{count} items",
|
txt_total_items_count: "{count} items",
|
||||||
txt_totp_secret: "TOTP Secret",
|
txt_totp_secret: "TOTP Secret",
|
||||||
txt_totp_verify_failed: "TOTP verify failed",
|
txt_totp_verify_failed: "TOTP verify failed",
|
||||||
txt_passkey: "Passkey",
|
|
||||||
txt_passkey_created_at_value: "Created at {value}",
|
|
||||||
txt_attachments: "Attachments",
|
txt_attachments: "Attachments",
|
||||||
txt_upload_attachments: "Upload attachments",
|
txt_upload_attachments: "Upload attachments",
|
||||||
txt_new_attachments: "New attachments",
|
txt_new_attachments: "New attachments",
|
||||||
@@ -1431,8 +1429,6 @@ zhCNOverrides.txt_lock = '锁定';
|
|||||||
zhCNOverrides.txt_menu = '菜单';
|
zhCNOverrides.txt_menu = '菜单';
|
||||||
zhCNOverrides.txt_settings = '设置';
|
zhCNOverrides.txt_settings = '设置';
|
||||||
zhCNOverrides.txt_back = '返回';
|
zhCNOverrides.txt_back = '返回';
|
||||||
zhCNOverrides.txt_passkey = 'Passkey';
|
|
||||||
zhCNOverrides.txt_passkey_created_at_value = '创建于 {value}';
|
|
||||||
zhCNOverrides.txt_attachments = '附件';
|
zhCNOverrides.txt_attachments = '附件';
|
||||||
zhCNOverrides.txt_upload_attachments = '上传附件';
|
zhCNOverrides.txt_upload_attachments = '上传附件';
|
||||||
zhCNOverrides.txt_new_attachments = '待上传附件';
|
zhCNOverrides.txt_new_attachments = '待上传附件';
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ export function makeLoginCipher(): Record<string, unknown> {
|
|||||||
favorite: false,
|
favorite: false,
|
||||||
reprompt: 0,
|
reprompt: 0,
|
||||||
key: null,
|
key: null,
|
||||||
login: { username: null, password: null, totp: null, fido2Credentials: null, uris: null },
|
login: { username: null, password: null, totp: null, uris: null },
|
||||||
card: null,
|
card: null,
|
||||||
identity: null,
|
identity: null,
|
||||||
secureNote: null,
|
secureNote: null,
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export interface BitwardenCipherInput {
|
|||||||
username?: string | null;
|
username?: string | null;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
totp?: string | null;
|
totp?: string | null;
|
||||||
fido2Credentials?: Array<Record<string, unknown>> | null;
|
|
||||||
} | null;
|
} | null;
|
||||||
card?: Record<string, unknown> | null;
|
card?: Record<string, unknown> | null;
|
||||||
identity?: Record<string, unknown> | null;
|
identity?: Record<string, unknown> | null;
|
||||||
@@ -90,7 +89,6 @@ export function normalizeBitwardenImport(raw: unknown): CiphersImportPayload {
|
|||||||
username: item.login.username ?? null,
|
username: item.login.username ?? null,
|
||||||
password: item.login.password ?? null,
|
password: item.login.password ?? null,
|
||||||
totp: item.login.totp ?? null,
|
totp: item.login.totp ?? null,
|
||||||
fido2Credentials: Array.isArray(item.login.fido2Credentials) ? item.login.fido2Credentials : null,
|
|
||||||
uris: Array.isArray(item.login.uris)
|
uris: Array.isArray(item.login.uris)
|
||||||
? item.login.uris.map((u) => ({ uri: u?.uri ?? null, match: u?.match ?? null }))
|
? item.login.uris.map((u) => ({ uri: u?.uri ?? null, match: u?.match ?? null }))
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
function base64UrlToBytes(input: string): Uint8Array {
|
|
||||||
const normalized = String(input || '').replace(/-/g, '+').replace(/_/g, '/');
|
|
||||||
const padded = normalized + '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
|
|
||||||
const binary = atob(padded);
|
|
||||||
const out = new Uint8Array(binary.length);
|
|
||||||
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bytesToBase64Url(bytes: ArrayBuffer | Uint8Array): string {
|
|
||||||
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
||||||
let binary = '';
|
|
||||||
for (const b of view) binary += String.fromCharCode(b);
|
|
||||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function passkeySupported(): boolean {
|
|
||||||
return typeof window !== 'undefined' && !!window.PublicKeyCredential;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createPasskeyCredential(publicKey: Record<string, any>): Promise<any> {
|
|
||||||
const options: PublicKeyCredentialCreationOptions = {
|
|
||||||
...(publicKey as PublicKeyCredentialCreationOptions),
|
|
||||||
challenge: base64UrlToBytes(publicKey.challenge),
|
|
||||||
user: {
|
|
||||||
...publicKey.user,
|
|
||||||
id: base64UrlToBytes(publicKey.user.id),
|
|
||||||
},
|
|
||||||
excludeCredentials: Array.isArray(publicKey.excludeCredentials)
|
|
||||||
? publicKey.excludeCredentials.map((item: any) => ({ ...item, id: base64UrlToBytes(item.id) }))
|
|
||||||
: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const credential = (await navigator.credentials.create({ publicKey: options })) as PublicKeyCredential | null;
|
|
||||||
if (!credential) throw new Error('Passkey creation was cancelled');
|
|
||||||
const response = credential.response as AuthenticatorAttestationResponse;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: credential.id,
|
|
||||||
rawId: bytesToBase64Url(credential.rawId),
|
|
||||||
type: credential.type,
|
|
||||||
response: {
|
|
||||||
clientDataJSON: bytesToBase64Url(response.clientDataJSON),
|
|
||||||
attestationObject: bytesToBase64Url(response.attestationObject),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requestPasskeyAssertion(publicKey: Record<string, any>): Promise<any> {
|
|
||||||
const options: PublicKeyCredentialRequestOptions = {
|
|
||||||
...(publicKey as PublicKeyCredentialRequestOptions),
|
|
||||||
challenge: base64UrlToBytes(publicKey.challenge),
|
|
||||||
allowCredentials: Array.isArray(publicKey.allowCredentials)
|
|
||||||
? publicKey.allowCredentials.map((item: any) => ({ ...item, id: base64UrlToBytes(item.id) }))
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const credential = (await navigator.credentials.get({ publicKey: options })) as PublicKeyCredential | null;
|
|
||||||
if (!credential) throw new Error('Passkey login was cancelled');
|
|
||||||
const response = credential.response as AuthenticatorAssertionResponse;
|
|
||||||
return {
|
|
||||||
id: credential.id,
|
|
||||||
rawId: bytesToBase64Url(credential.rawId),
|
|
||||||
type: credential.type,
|
|
||||||
response: {
|
|
||||||
clientDataJSON: bytesToBase64Url(response.clientDataJSON),
|
|
||||||
authenticatorData: bytesToBase64Url(response.authenticatorData),
|
|
||||||
signature: bytesToBase64Url(response.signature),
|
|
||||||
userHandle: response.userHandle ? bytesToBase64Url(response.userHandle) : null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -48,17 +48,11 @@ export interface CipherAttachment {
|
|||||||
object?: string;
|
object?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CipherLoginPasskey {
|
|
||||||
creationDate?: string | null;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CipherLogin {
|
export interface CipherLogin {
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
totp?: string | null;
|
totp?: string | null;
|
||||||
uris?: CipherLoginUri[] | null;
|
uris?: CipherLoginUri[] | null;
|
||||||
fido2Credentials?: CipherLoginPasskey[] | null;
|
|
||||||
decUsername?: string;
|
decUsername?: string;
|
||||||
decPassword?: string;
|
decPassword?: string;
|
||||||
decTotp?: string;
|
decTotp?: string;
|
||||||
@@ -228,7 +222,6 @@ export interface VaultDraft {
|
|||||||
loginPassword: string;
|
loginPassword: string;
|
||||||
loginTotp: string;
|
loginTotp: string;
|
||||||
loginUris: VaultDraftLoginUri[];
|
loginUris: VaultDraftLoginUri[];
|
||||||
loginFido2Credentials: Array<Record<string, unknown>>;
|
|
||||||
cardholderName: string;
|
cardholderName: string;
|
||||||
cardNumber: string;
|
cardNumber: string;
|
||||||
cardBrand: string;
|
cardBrand: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user