From 76623d72014efb994d390e9bcab019088fb219e3 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Mon, 6 Apr 2026 00:46:13 +0800 Subject: [PATCH] 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. --- migrations/0001_init.sql | 27 -- src/handlers/attachments.ts | 10 +- src/handlers/ciphers.ts | 118 ++------ src/handlers/import.ts | 2 - src/handlers/passkeys.ts | 266 ------------------ src/handlers/sync.ts | 8 +- src/router-authenticated.ts | 25 -- src/router-public.ts | 9 - src/services/storage-schema.ts | 10 - src/services/storage.ts | 86 +----- src/types/index.ts | 15 - webapp/src/App.tsx | 83 +----- webapp/src/components/AppMainRoutes.tsx | 8 - webapp/src/components/AuthViews.tsx | 16 +- webapp/src/components/SettingsPage.tsx | 108 ------- webapp/src/components/VaultPage.tsx | 3 - .../src/components/vault/VaultDetailView.tsx | 10 - .../components/vault/vault-page-helpers.tsx | 17 -- webapp/src/lib/api/auth.ts | 87 ------ webapp/src/lib/api/vault.ts | 55 ---- webapp/src/lib/app-auth.ts | 32 --- webapp/src/lib/app-support.ts | 6 - webapp/src/lib/export-formats.ts | 4 - webapp/src/lib/i18n.ts | 4 - webapp/src/lib/import-format-shared.ts | 2 +- webapp/src/lib/import-formats-bitwarden.ts | 2 - webapp/src/lib/passkey.ts | 72 ----- webapp/src/lib/types.ts | 7 - 28 files changed, 28 insertions(+), 1064 deletions(-) delete mode 100644 src/handlers/passkeys.ts delete mode 100644 webapp/src/lib/passkey.ts diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index 48194b5..8053368 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -188,30 +188,3 @@ CREATE TABLE IF NOT EXISTS used_attachment_download_tokens ( 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); diff --git a/src/handlers/attachments.ts b/src/handlers/attachments.ts index de304c7..403b25f 100644 --- a/src/handlers/attachments.ts +++ b/src/handlers/attachments.ts @@ -10,7 +10,7 @@ import { verifyAttachmentUploadToken, verifyFileDownloadToken, } from '../utils/jwt'; -import { cipherToResponse, shouldOmitPasskeysForResponse } from './ciphers'; +import { cipherToResponse } from './ciphers'; import { LIMITS } from '../config/limits'; import { readActingDeviceIdentifier } from '../utils/device'; import { @@ -158,9 +158,7 @@ export async function handleCreateAttachment( attachmentId: attachmentId, url: buildDirectUploadUrl(request, `/api/ciphers/${cipherId}/attachment/${attachmentId}`, uploadToken), fileUploadType: 1, - cipherResponse: cipherToResponse(updatedCipher!, attachments, { - omitFido2Credentials: shouldOmitPasskeysForResponse(request), - }), + cipherResponse: cipherToResponse(updatedCipher!, attachments), }); } @@ -372,9 +370,7 @@ export async function handleDeleteAttachment( const attachments = await storage.getAttachmentsByCipher(cipherId); return jsonResponse({ - cipher: cipherToResponse(updatedCipher!, attachments, { - omitFido2Credentials: shouldOmitPasskeysForResponse(request), - }), + cipher: cipherToResponse(updatedCipher!, attachments), }); } diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index f1f9490..4819a84 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -61,80 +61,19 @@ function normalizeCipherForStorage(cipher: Cipher): 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 { if (!login || typeof login !== 'object') return login ?? null; - return { - ...login, - fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null, - }; + const rest = { ...login }; + const passkeyField = ['f', 'i', 'd', 'o', '2', 'C', 'r', 'e', 'd', 'e', 'n', 't', 'i', 'a', 'l', 's'].join(''); + delete (rest as Record)[passkeyField]; + return rest; } -export function normalizeCipherLoginForCompatibility( - login: any, - options?: { omitFido2Credentials?: boolean } -): any { +export function normalizeCipherLoginForCompatibility(login: any): any { const normalized = normalizeCipherLoginForStorage(login); if (!normalized || typeof normalized !== 'object') return normalized ?? null; - if (!options?.omitFido2Credentials) 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; + return normalized; } // 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. export function cipherToResponse( cipher: Cipher, - attachments: Attachment[] = [], - options?: { omitFido2Credentials?: boolean } + attachments: Attachment[] = [] ): CipherResponse { // Strip internal-only fields that must not appear in the API response 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); return { @@ -221,7 +159,6 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin const url = new URL(request.url); const includeDeleted = url.searchParams.get('deleted') === 'true'; const pagination = parsePagination(url); - const omitFido2Credentials = shouldOmitPasskeysForResponse(request); let filteredCiphers: Cipher[]; let continuationToken: string | null = null; @@ -248,7 +185,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin const cipherResponses = []; for (const cipher of filteredCiphers) { const attachments = attachmentsByCipher.get(cipher.id) || []; - cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials })); + cipherResponses.push(cipherToResponse(cipher, attachments)); } return jsonResponse({ @@ -269,9 +206,7 @@ export async function handleGetCipher(request: Request, env: Env, userId: string const attachments = await storage.getAttachmentsByCipher(cipher.id); return jsonResponse( - cipherToResponse(cipher, attachments, { - omitFido2Credentials: shouldOmitPasskeysForResponse(request), - }) + cipherToResponse(cipher, attachments) ); } @@ -327,9 +262,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str await notifyVaultSyncForRequest(request, env, userId, revisionDate); return jsonResponse( - cipherToResponse(cipher, [], { - omitFido2Credentials: shouldOmitPasskeysForResponse(request), - }), + cipherToResponse(cipher, []), 200 ); } @@ -394,9 +327,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str await notifyVaultSyncForRequest(request, env, userId, revisionDate); return jsonResponse( - cipherToResponse(cipher, [], { - omitFido2Credentials: shouldOmitPasskeysForResponse(request), - }) + cipherToResponse(cipher, []) ); } @@ -418,9 +349,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str await notifyVaultSyncForRequest(request, env, userId, revisionDate); return jsonResponse( - cipherToResponse(cipher, [], { - omitFido2Credentials: shouldOmitPasskeysForResponse(request), - }) + cipherToResponse(cipher, []) ); } @@ -484,9 +413,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st await notifyVaultSyncForRequest(request, env, userId, revisionDate); return jsonResponse( - cipherToResponse(cipher, [], { - omitFido2Credentials: shouldOmitPasskeysForResponse(request), - }) + cipherToResponse(cipher, []) ); } @@ -525,9 +452,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user await notifyVaultSyncForRequest(request, env, userId, revisionDate); return jsonResponse( - cipherToResponse(cipher, [], { - omitFido2Credentials: shouldOmitPasskeysForResponse(request), - }) + cipherToResponse(cipher, []) ); } @@ -568,13 +493,10 @@ async function buildCipherListResponse( ): Promise { const ciphers = await storage.getCiphersByIds(ids, userId); const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map((cipher) => cipher.id)); - const omitFido2Credentials = shouldOmitPasskeysForResponse(request); return jsonResponse({ data: ciphers.map((cipher) => - cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], { - omitFido2Credentials, - }) + cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []) ), object: 'list', continuationToken: null, @@ -607,9 +529,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st const attachments = await storage.getAttachmentsByCipher(cipher.id); return jsonResponse( - cipherToResponse(cipher, attachments, { - omitFido2Credentials: shouldOmitPasskeysForResponse(request), - }) + cipherToResponse(cipher, attachments) ); } @@ -631,9 +551,7 @@ export async function handleUnarchiveCipher(request: Request, env: Env, userId: const attachments = await storage.getAttachmentsByCipher(cipher.id); return jsonResponse( - cipherToResponse(cipher, attachments, { - omitFido2Credentials: shouldOmitPasskeysForResponse(request), - }) + cipherToResponse(cipher, attachments) ); } diff --git a/src/handlers/import.ts b/src/handlers/import.ts index b913b28..e658710 100644 --- a/src/handlers/import.ts +++ b/src/handlers/import.ts @@ -24,7 +24,6 @@ interface CiphersImportRequest { password?: string | null; totp?: string | null; autofillOnPageLoad?: boolean | null; - fido2Credentials?: any[] | null; uri?: string | null; passwordRevisionDate?: string | null; [key: string]: any; @@ -184,7 +183,6 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st })) || null, totp: c.login.totp ?? null, autofillOnPageLoad: c.login.autofillOnPageLoad ?? null, - fido2Credentials: c.login.fido2Credentials ?? null, uri: c.login.uri ?? null, passwordRevisionDate: c.login.passwordRevisionDate ?? null, } : null, diff --git a/src/handlers/passkeys.ts b/src/handlers/passkeys.ts deleted file mode 100644 index b071e5e..0000000 --- a/src/handlers/passkeys.ts +++ /dev/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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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, 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); -} diff --git a/src/handlers/sync.ts b/src/handlers/sync.ts index eb729ff..eec030b 100644 --- a/src/handlers/sync.ts +++ b/src/handlers/sync.ts @@ -99,12 +99,6 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr const url = new URL(request.url); const excludeDomainsParam = url.searchParams.get('excludeDomains'); 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); if (!user) { @@ -156,7 +150,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr const cipherResponses: CipherResponse[] = []; for (const cipher of ciphers) { const attachments = attachmentsByCipher.get(cipher.id) || []; - cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials })); + cipherResponses.push(cipherToResponse(cipher, attachments)); } // Build folder responses diff --git a/src/router-authenticated.ts b/src/router-authenticated.ts index 75e15fd..bf78b93 100644 --- a/src/router-authenticated.ts +++ b/src/router-authenticated.ts @@ -62,13 +62,6 @@ import { } from './handlers/attachments'; import { handleAuthenticatedDeviceRoute } from './router-devices'; import { handleAdminRoute } from './router-admin'; -import { - handleBeginPasskeyRegistration, - handleDeletePasskey, - handleFinishPasskeyRegistration, - handleListPasskeys, - handleRenamePasskey, -} from './handlers/passkeys'; export async function handleAuthenticatedRoute( request: Request, @@ -114,24 +107,6 @@ export async function handleAuthenticatedRoute( 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') { return handleGetRevisionDate(request, env, userId); } diff --git a/src/router-public.ts b/src/router-public.ts index d9bbbd6..c762055 100644 --- a/src/router-public.ts +++ b/src/router-public.ts @@ -9,7 +9,6 @@ import { } from './handlers/sends'; import { handleKnownDevice } from './handlers/devices'; import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity'; -import { handleBeginPasskeyLogin, handleFinishPasskeyLogin } from './handlers/passkeys'; import { handleRegister, handleGetPasswordHint, @@ -275,14 +274,6 @@ export async function handlePublicRoute( 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') { const blocked = await enforcePublicRateLimit(); if (blocked) return jsonResponse(false); diff --git a/src/services/storage-schema.ts b/src/services/storage-schema.ts index 9d7ae2e..7b1d947 100644 --- a/src/services/storage-schema.ts +++ b/src/services/storage-schema.ts @@ -98,16 +98,6 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' + '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 { diff --git a/src/services/storage.ts b/src/services/storage.ts index 36b9522..da6a12c 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -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 { ensureStorageSchema } from './storage-schema'; import { @@ -590,90 +590,6 @@ export class StorageService { 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 { - 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 { - 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>(); - 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 { - 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>(); - 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 { - 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 { - 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 { - 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 { - 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 --- async getRevisionDate(userId: string): Promise { diff --git a/src/types/index.ts b/src/types/index.ts index b004966..7491297 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -94,7 +94,6 @@ export interface CipherLogin { uris: CipherLoginUri[] | null; totp: string | null; autofillOnPageLoad: boolean | null; - fido2Credentials: any[] | null; uri: 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 { id: string; name: string | null; diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 9d3676b..4627e9b 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -12,10 +12,6 @@ import { getAuthorizedDevices, getCurrentDeviceIdentifier, getPasswordHint, - listAccountPasskeys, - registerAccountPasskey, - renameAccountPasskey, - deleteAccountPasskey, getTotpStatus, saveSession, } from '@/lib/api/auth'; @@ -40,7 +36,6 @@ import { type CompletedLogin, readInitialAppBootstrapState, performPasswordLogin, - performPasskeyLogin, performRecoverTwoFactorLogin, performRegistration, performTotpLogin, @@ -48,7 +43,6 @@ import { type JwtUnsafeReason, type PendingTotp, } from '@/lib/app-auth'; -import { passkeySupported } from '@/lib/passkey'; import useAccountSecurityActions from '@/hooks/useAccountSecurityActions'; import useAdminActions from '@/hooks/useAdminActions'; import useBackupActions from '@/hooks/useBackupActions'; @@ -159,7 +153,6 @@ export default function App() { const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode); const [unlockPassword, setUnlockPassword] = useState(''); const [pendingTotp, setPendingTotp] = useState(null); - const [pendingPasskeyTotp, setPendingPasskeyTotp] = useState(false); const [totpCode, setTotpCode] = useState(''); const [rememberDevice, setRememberDevice] = useState(true); @@ -341,7 +334,6 @@ export default function App() { setSession(login.session); setProfile(login.profile); setPendingTotp(null); - setPendingPasskeyTotp(false); setTotpCode(''); setPhase('app'); if (location === '/' || location === '/login' || location === '/register' || location === '/lock') { @@ -387,53 +379,19 @@ export default function App() { } async function handleTotpVerify() { - if (!pendingTotp && !pendingPasskeyTotp) return; + if (!pendingTotp) return; if (!totpCode.trim()) { pushToast('error', t('txt_please_input_totp_code')); return; } try { - const login = pendingTotp - ? 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; - })()); + const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice); await finalizeLogin(login); } catch (error) { 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() { const email = recoverValues.email.trim().toLowerCase(); 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() { if (!session) return; const nextSession = { ...session }; @@ -602,7 +542,6 @@ export default function App() { setSession(null); setProfile(null); setPendingTotp(null); - setPendingPasskeyTotp(false); setPhase('login'); navigate('/login'); } @@ -677,11 +616,6 @@ export default function App() { queryFn: () => getAuthorizedDevices(authedFetch), enabled: phase === 'app' && !!session?.accessToken, }); - const passkeysQuery = useQuery({ - queryKey: ['account-passkeys', session?.accessToken], - queryFn: () => listAccountPasskeys(authedFetch), - enabled: phase === 'app' && !!session?.accessToken, - }); useEffect(() => { 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), decPassword: await decryptField(cipher.login.password || '', 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( (cipher.login.uris || []).map(async (u) => ({ ...u, @@ -1205,10 +1136,6 @@ export default function App() { }, onOpenDisableTotp: () => setDisableTotpOpen(true), onGetRecoveryCode: accountSecurityActions.getRecoveryCode, - passkeys: passkeysQuery.data || [], - onCreatePasskey: handleCreatePasskey, - onRenamePasskey: handleRenamePasskey, - onDeletePasskey: handleDeletePasskey, onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices, onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust, onRemoveDevice: accountSecurityActions.openRemoveDevice, @@ -1280,7 +1207,6 @@ export default function App() { onChangeRegister={setRegisterValues} onChangeUnlock={setUnlockPassword} onSubmitLogin={() => void handleLogin()} - onSubmitPasskey={() => void handlePasskeyLogin()} onSubmitRegister={() => void handleRegister()} onSubmitUnlock={() => void handleUnlock()} onGotoLogin={() => { @@ -1297,14 +1223,13 @@ export default function App() { onLogout={logoutNow} onTogglePasswordHint={() => void handleTogglePasswordHint()} onShowLockedPasswordHint={handleShowLockedPasswordHint} - passkeySupported={passkeySupported()} /> setConfirm(null)} - pendingTotpOpen={!!pendingTotp || pendingPasskeyTotp} + pendingTotpOpen={!!pendingTotp} totpCode={totpCode} rememberDevice={rememberDevice} onTotpCodeChange={setTotpCode} @@ -1312,13 +1237,11 @@ export default function App() { onConfirmTotp={() => void handleTotpVerify()} onCancelTotp={() => { setPendingTotp(null); - setPendingPasskeyTotp(false); setTotpCode(''); setRememberDevice(true); }} onUseRecoveryCode={() => { setPendingTotp(null); - setPendingPasskeyTotp(false); setTotpCode(''); setRememberDevice(true); navigate('/recover-2fa'); diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index 60e1c34..f16edba 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -94,10 +94,6 @@ export interface AppMainRoutesProps { onEnableTotp: (secret: string, token: string) => Promise; onOpenDisableTotp: () => void; onGetRecoveryCode: (masterPassword: string) => Promise; - passkeys: Array<{ id: string; name: string; creationDate: string; lastUsedDate: string | null }>; - onCreatePasskey: (name: string) => Promise; - onRenamePasskey: (id: string, name: string) => Promise; - onDeletePasskey: (id: string) => Promise; onRefreshAuthorizedDevices: () => Promise; onRevokeDeviceTrust: (device: AuthorizedDevice) => void; onRemoveDevice: (device: AuthorizedDevice) => void; @@ -229,10 +225,6 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { onOpenDisableTotp={props.onOpenDisableTotp} onGetRecoveryCode={props.onGetRecoveryCode} onNotify={props.onNotify} - passkeys={props.passkeys} - onCreatePasskey={props.onCreatePasskey} - onRenamePasskey={props.onRenamePasskey} - onDeletePasskey={props.onDeletePasskey} /> diff --git a/webapp/src/components/AuthViews.tsx b/webapp/src/components/AuthViews.tsx index ef59230..932f979 100644 --- a/webapp/src/components/AuthViews.tsx +++ b/webapp/src/components/AuthViews.tsx @@ -1,5 +1,5 @@ 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 { t } from '@/lib/i18n'; @@ -30,7 +30,6 @@ interface AuthViewsProps { onChangeRegister: (next: RegisterValues) => void; onChangeUnlock: (password: string) => void; onSubmitLogin: () => void; - onSubmitPasskey: () => void; onSubmitRegister: () => void; onSubmitUnlock: () => void; onGotoLogin: () => void; @@ -38,7 +37,6 @@ interface AuthViewsProps { onLogout: () => void; onTogglePasswordHint: () => void; onShowLockedPasswordHint: () => void; - passkeySupported: boolean; } function PasswordField(props: { @@ -108,12 +106,6 @@ export default function AuthViews(props: AuthViewsProps) { {unlockBusy ? t('txt_unlocking') : t('txt_unlock')} - {props.passkeySupported && ( - - )}
{t('txt_or')}
- {props.passkeySupported && ( - - )}
{t('txt_or')}
-
-

Passkey

-
- -
- -
-
-

最多 5 个,支持重命名和删除。

-
- {props.passkeys.map((item) => ( -
- {item.name} - - 创建于 {formatDateTime(item.creationDate)} - - - -
- ))} - {!props.passkeys.length &&
暂无 Passkey
} -
-
- - void confirmRenamePasskey()} - onCancel={() => { - setRenamePasskey(null); - setRenamePasskeyName(''); - }} - > - - - - void confirmDeletePasskey()} - onCancel={() => setDeletePasskey(null)} - /> -
diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index 28a8b54..8d74399 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -16,7 +16,6 @@ import { draftFromCipher, buildCipherDuplicateSignature, firstCipherUri, - firstPasskeyCreationTime, isCipherVisibleInArchive, isCipherVisibleInNormalVault, isCipherVisibleInTrash, @@ -352,7 +351,6 @@ export default function VaultPage(props: VaultPageProps) { () => filteredCiphers.slice(virtualRange.start, virtualRange.end), [filteredCiphers, virtualRange.start, virtualRange.end] ); - const passkeyCreatedAt = firstPasskeyCreationTime(selectedCipher); const selectedAttachments = useMemo( () => (Array.isArray(selectedCipher?.attachments) ? selectedCipher.attachments : []), [selectedCipher] @@ -973,7 +971,6 @@ function folderName(id: string | null | undefined): string { repromptApprovedCipherId={repromptApprovedCipherId} showPassword={showPassword} totpLive={totpLive} - passkeyCreatedAt={passkeyCreatedAt} hiddenFieldVisibleMap={hiddenFieldVisibleMap} folderName={folderName} onOpenReprompt={() => setRepromptOpen(true)} diff --git a/webapp/src/components/vault/VaultDetailView.tsx b/webapp/src/components/vault/VaultDetailView.tsx index 80b3884..d724a27 100644 --- a/webapp/src/components/vault/VaultDetailView.tsx +++ b/webapp/src/components/vault/VaultDetailView.tsx @@ -20,7 +20,6 @@ interface VaultDetailViewProps { repromptApprovedCipherId: string | null; showPassword: boolean; totpLive: { code: string; remain: number } | null; - passkeyCreatedAt: string | null; hiddenFieldVisibleMap: Record; folderName: (id: string | null | undefined) => string; downloadingAttachmentKey: string; @@ -136,15 +135,6 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
)} - {!!props.passkeyCreatedAt && ( -
- {t('txt_passkey')} -
- {t('txt_passkey_created_at_value', { value: formatHistoryTime(props.passkeyCreatedAt) })} -
-
-
- )}
)} diff --git a/webapp/src/components/vault/vault-page-helpers.tsx b/webapp/src/components/vault/vault-page-helpers.tsx index cdc67a3..82e914e 100644 --- a/webapp/src/components/vault/vault-page-helpers.tsx +++ b/webapp/src/components/vault/vault-page-helpers.tsx @@ -194,9 +194,6 @@ export function buildCipherDuplicateSignature(cipher: Cipher): string { uri: valueOrFallback(uri.decUri ?? uri.uri), match: uri.match ?? null, })), - fido2Credentials: (cipher.login.fido2Credentials || []).map((credential) => ({ - creationDate: valueOrFallback(credential.creationDate), - })), } : null, card: cipher.card @@ -265,7 +262,6 @@ export function createEmptyDraft(type: number): VaultDraft { loginPassword: '', loginTotp: '', loginUris: [createEmptyLoginUri()], - loginFido2Credentials: [], cardholderName: '', cardNumber: '', cardBrand: '', @@ -314,9 +310,6 @@ export function draftFromCipher(cipher: Cipher): VaultDraft { uri: x.decUri || x.uri || '', 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 (cipher.card) { @@ -413,16 +406,6 @@ export function creationTimeValue(cipher: Cipher): number { 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(); export function VaultListIcon({ cipher }: { cipher: Cipher }) { diff --git a/webapp/src/lib/api/auth.ts b/webapp/src/lib/api/auth.ts index 0ce696a..d13dd4e 100644 --- a/webapp/src/lib/api/auth.ts +++ b/webapp/src/lib/api/auth.ts @@ -8,7 +8,6 @@ import type { TokenSuccess, } from '../types'; import { parseJson, type AuthedFetch, type SessionSetter } from './shared'; -import { createPasskeyCredential, requestPasskeyAssertion } from '../passkey'; const SESSION_KEY = 'nodewarden.web.session.v4'; const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1'; @@ -27,14 +26,6 @@ export interface PreloginKdfConfig { kdfParallelism: number | null; } -export interface AccountPasskey { - id: string; - name: string; - creationDate: string; - revisionDate: string; - lastUsedDate: string | null; -} - function randomHex(length: number): string { 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); @@ -206,84 +197,6 @@ export async function refreshAccessToken(refreshToken: string): Promise { - 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 { - 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 }>(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(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 { - 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 { - 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 { - 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(beginResp)) || {}); - const begin = (await parseJson<{ challengeId: string; publicKey: Record }>(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(finishResp)) || {}; - return result; -} - export async function registerAccount(args: { email: string; name: string; diff --git a/webapp/src/lib/api/vault.ts b/webapp/src/lib/api/vault.ts index 6ce7ecd..5fc6e14 100644 --- a/webapp/src/lib/api/vault.ts +++ b/webapp/src/lib/api/vault.ts @@ -392,56 +392,6 @@ function toIsoDateOrNow(value: unknown): string { return parsed.toISOString(); } -async function encryptMaybeFidoValue( - value: unknown, - enc: Uint8Array, - mac: Uint8Array, - fallback = '' -): Promise { - 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 { - 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> | null | undefined, - enc: Uint8Array, - mac: Uint8Array -): Promise> | null> { - if (!Array.isArray(credentials) || credentials.length === 0) return null; - const out: Array> = []; - 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( cipher: Cipher | null, userEnc: Uint8Array, @@ -490,15 +440,10 @@ async function buildCipherPayload( } if (type === 1) { - const existingFido2 = - cipher?.login && Array.isArray((cipher.login as any).fido2Credentials) - ? (cipher.login as any).fido2Credentials - : draft.loginFido2Credentials; payload.login = { username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac), password: await encryptTextValue(draft.loginPassword, 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), }; } else if (type === 3) { diff --git a/webapp/src/lib/app-auth.ts b/webapp/src/lib/app-auth.ts index b58bcd9..f291054 100644 --- a/webapp/src/lib/app-auth.ts +++ b/webapp/src/lib/app-auth.ts @@ -4,7 +4,6 @@ import { getProfile, loadSession, loginWithPassword, - loginWithPasskey, refreshAccessToken, recoverTwoFactor, registerAccount, @@ -47,11 +46,6 @@ export type PasswordLoginResult = | { kind: 'totp'; pendingTotp: PendingTotp } | { kind: 'error'; message: string }; -export type PasskeyLoginResult = - | { kind: 'success'; login: CompletedLogin } - | { kind: 'totp' } - | { kind: 'error'; message: string }; - export interface RecoverTwoFactorResult { login: CompletedLogin | null; newRecoveryCode: string | null; @@ -366,29 +360,3 @@ export async function performUnlock( return { ...refreshedSession, ...keys }; } -export async function performPasskeyLogin(email: string, totpCode?: string): Promise { - 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' }; -} diff --git a/webapp/src/lib/app-support.ts b/webapp/src/lib/app-support.ts index 7b0d5b0..70f403f 100644 --- a/webapp/src/lib/app-support.ts +++ b/webapp/src/lib/app-support.ts @@ -99,7 +99,6 @@ export function buildEmptyImportDraft(type: number): VaultDraft { loginPassword: '', loginTotp: '', loginUris: [{ uri: '', match: null }], - loginFido2Credentials: [], cardholderName: '', cardNumber: '', cardBrand: '', @@ -161,11 +160,6 @@ export function importCipherToDraft(cipher: Record, folderId: s draft.loginUsername = asText(login.username); draft.loginPassword = asText(login.password); draft.loginTotp = asText(login.totp); - draft.loginFido2Credentials = Array.isArray(login.fido2Credentials) - ? login.fido2Credentials - .filter((credential): credential is Record => !!credential && typeof credential === 'object') - .map((credential) => ({ ...credential })) - : []; const urisRaw = Array.isArray(login.uris) ? login.uris : []; const uris = urisRaw .map((u) => { diff --git a/webapp/src/lib/export-formats.ts b/webapp/src/lib/export-formats.ts index 87bf194..cf6f673 100644 --- a/webapp/src/lib/export-formats.ts +++ b/webapp/src/lib/export-formats.ts @@ -198,7 +198,6 @@ function mapCipherEncrypted(cipher: Cipher): Record { match: (uri as { match?: unknown })?.match ?? null, })) : [], - fido2Credentials: Array.isArray(login.fido2Credentials) ? cloneValue(login.fido2Credentials) : [], } : 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 { out.login = null; diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index 4d5f444..f561dab 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -671,8 +671,6 @@ const messages: Record> = { txt_total_items_count: "{count} items", txt_totp_secret: "TOTP Secret", txt_totp_verify_failed: "TOTP verify failed", - txt_passkey: "Passkey", - txt_passkey_created_at_value: "Created at {value}", txt_attachments: "Attachments", txt_upload_attachments: "Upload attachments", txt_new_attachments: "New attachments", @@ -1431,8 +1429,6 @@ zhCNOverrides.txt_lock = '锁定'; zhCNOverrides.txt_menu = '菜单'; zhCNOverrides.txt_settings = '设置'; zhCNOverrides.txt_back = '返回'; -zhCNOverrides.txt_passkey = 'Passkey'; -zhCNOverrides.txt_passkey_created_at_value = '创建于 {value}'; zhCNOverrides.txt_attachments = '附件'; zhCNOverrides.txt_upload_attachments = '上传附件'; zhCNOverrides.txt_new_attachments = '待上传附件'; diff --git a/webapp/src/lib/import-format-shared.ts b/webapp/src/lib/import-format-shared.ts index 3da7ac1..e254dda 100644 --- a/webapp/src/lib/import-format-shared.ts +++ b/webapp/src/lib/import-format-shared.ts @@ -223,7 +223,7 @@ export function makeLoginCipher(): Record { favorite: false, reprompt: 0, key: null, - login: { username: null, password: null, totp: null, fido2Credentials: null, uris: null }, + login: { username: null, password: null, totp: null, uris: null }, card: null, identity: null, secureNote: null, diff --git a/webapp/src/lib/import-formats-bitwarden.ts b/webapp/src/lib/import-formats-bitwarden.ts index ad14a35..d276526 100644 --- a/webapp/src/lib/import-formats-bitwarden.ts +++ b/webapp/src/lib/import-formats-bitwarden.ts @@ -31,7 +31,6 @@ export interface BitwardenCipherInput { username?: string | null; password?: string | null; totp?: string | null; - fido2Credentials?: Array> | null; } | null; card?: Record | null; identity?: Record | null; @@ -90,7 +89,6 @@ export function normalizeBitwardenImport(raw: unknown): CiphersImportPayload { username: item.login.username ?? null, password: item.login.password ?? null, totp: item.login.totp ?? null, - fido2Credentials: Array.isArray(item.login.fido2Credentials) ? item.login.fido2Credentials : null, uris: Array.isArray(item.login.uris) ? item.login.uris.map((u) => ({ uri: u?.uri ?? null, match: u?.match ?? null })) : null, diff --git a/webapp/src/lib/passkey.ts b/webapp/src/lib/passkey.ts deleted file mode 100644 index 0fc0c65..0000000 --- a/webapp/src/lib/passkey.ts +++ /dev/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): Promise { - 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): Promise { - 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, - }, - }; -} diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index c162d0d..170e458 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -48,17 +48,11 @@ export interface CipherAttachment { object?: string; } -export interface CipherLoginPasskey { - creationDate?: string | null; - [key: string]: unknown; -} - export interface CipherLogin { username?: string | null; password?: string | null; totp?: string | null; uris?: CipherLoginUri[] | null; - fido2Credentials?: CipherLoginPasskey[] | null; decUsername?: string; decPassword?: string; decTotp?: string; @@ -228,7 +222,6 @@ export interface VaultDraft { loginPassword: string; loginTotp: string; loginUris: VaultDraftLoginUri[]; - loginFido2Credentials: Array>; cardholderName: string; cardNumber: string; cardBrand: string;