mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +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:
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<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 { 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<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 ---
|
||||
|
||||
async getRevisionDate(userId: string): Promise<string> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user