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:
shuaiplus
2026-04-06 00:46:13 +08:00
parent 90a7731351
commit 76623d7201
28 changed files with 28 additions and 1064 deletions
+3 -7
View File
@@ -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),
});
}
+18 -100
View File
@@ -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<string, unknown>)[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<Response> {
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)
);
}
-2
View File
@@ -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,
-266
View File
@@ -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);
}
+1 -7
View File
@@ -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