mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: implement account passkey functionality
- Added functions for managing account passkeys including creation, listing, updating, and deletion. - Introduced login methods using account passkeys with options for direct unlock and login-only modes. - Enhanced error handling and response parsing for passkey-related API calls. - Updated UI styles for account passkey management components. - Added new translations for account passkey features in multiple languages. - Modified network status handling to improve service reachability checks.
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from '@simplewebauthn/server';
|
||||
import type { AccountPasskeyChallengeScope, AccountPasskeyCredential, Env, User } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { AuthService } from '../services/auth';
|
||||
import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { bytesToBase64Url } from '../utils/passkey';
|
||||
import {
|
||||
accountPasskeyCredentialToResponse,
|
||||
accountPasskeyPrfStatus,
|
||||
accountPasskeyTokenTtlMs,
|
||||
buildWebAuthnPrfOption,
|
||||
createAccountPasskeyToken,
|
||||
getAccountPasskeyRpConfig,
|
||||
isSerializedEncString,
|
||||
normalizeAccountPasskeyName,
|
||||
normalizeAuthenticationResponse,
|
||||
normalizeRegistrationResponse,
|
||||
normalizeTransports,
|
||||
sha256Base64Url,
|
||||
toSimpleWebAuthnCredential,
|
||||
userHandleToUserId,
|
||||
userIdToWebAuthnUserId,
|
||||
verifyAccountPasskeyToken,
|
||||
} from '../utils/account-passkeys';
|
||||
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
|
||||
|
||||
const MAX_ACCOUNT_PASSKEYS = 5;
|
||||
|
||||
function parseBodyObject(body: unknown): Record<string, any> {
|
||||
return body && typeof body === 'object' ? body as Record<string, any> : {};
|
||||
}
|
||||
|
||||
async function readJsonBody(request: Request): Promise<Record<string, any> | null> {
|
||||
try {
|
||||
return parseBodyObject(await request.json());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyUserSecret(
|
||||
env: Env,
|
||||
user: User,
|
||||
body: Record<string, any>
|
||||
): Promise<boolean> {
|
||||
const secret = String(body.masterPasswordHash || body.master_password_hash || body.secret || body.password || '').trim();
|
||||
if (!secret) return false;
|
||||
const storedHash = String(user.masterPasswordHash || '').trim();
|
||||
if (!storedHash) return false;
|
||||
const auth = new AuthService(env);
|
||||
return auth.verifyPassword(secret, storedHash, user.email);
|
||||
}
|
||||
|
||||
function logAccountPasskeyHandlerError(stage: string, error: unknown, details: Record<string, unknown> = {}): void {
|
||||
const err = error instanceof Error ? error : null;
|
||||
console.error('Account passkey handler failed', {
|
||||
stage,
|
||||
name: err?.name || typeof error,
|
||||
message: err?.message || String(error),
|
||||
stack: err?.stack,
|
||||
...details,
|
||||
});
|
||||
}
|
||||
|
||||
function passkeySetupStageMessage(stage: string): string {
|
||||
if (stage === 'verify_master_password') return 'verifying master password';
|
||||
if (stage === 'load_existing_credentials') return 'loading existing passkeys';
|
||||
if (stage === 'generate_options') return 'generating passkey options';
|
||||
if (stage === 'save_challenge') return 'saving passkey challenge';
|
||||
if (stage === 'create_token') return 'creating passkey challenge token';
|
||||
return 'preparing passkey setup';
|
||||
}
|
||||
|
||||
function hasCompletePrfKeySet(body: Record<string, any>): boolean {
|
||||
return !!(body.encryptedUserKey && body.encryptedPublicKey && body.encryptedPrivateKey);
|
||||
}
|
||||
|
||||
function readPrfKeySet(body: Record<string, any>): {
|
||||
encryptedUserKey: string | null;
|
||||
encryptedPublicKey: string | null;
|
||||
encryptedPrivateKey: string | null;
|
||||
} {
|
||||
if (!hasCompletePrfKeySet(body)) {
|
||||
return { encryptedUserKey: null, encryptedPublicKey: null, encryptedPrivateKey: null };
|
||||
}
|
||||
const encryptedUserKey = String(body.encryptedUserKey).trim();
|
||||
const encryptedPublicKey = String(body.encryptedPublicKey).trim();
|
||||
const encryptedPrivateKey = String(body.encryptedPrivateKey).trim();
|
||||
if (!isSerializedEncString(encryptedUserKey) || !isSerializedEncString(encryptedPublicKey) || !isSerializedEncString(encryptedPrivateKey)) {
|
||||
throw new Error('Invalid encrypted key set');
|
||||
}
|
||||
return { encryptedUserKey, encryptedPublicKey, encryptedPrivateKey };
|
||||
}
|
||||
|
||||
async function saveChallenge(
|
||||
storage: StorageService,
|
||||
scope: AccountPasskeyChallengeScope,
|
||||
challenge: string,
|
||||
userId: string | null
|
||||
): Promise<void> {
|
||||
const now = Date.now();
|
||||
await storage.saveAccountPasskeyChallenge({
|
||||
challengeHash: await sha256Base64Url(challenge),
|
||||
scope,
|
||||
userId,
|
||||
expiresAt: now + accountPasskeyTokenTtlMs(scope),
|
||||
usedAt: null,
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleGetAccountPasskeyAssertionOptions(request: Request, env: Env): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const { rpId } = getAccountPasskeyRpConfig(request, env);
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rpId,
|
||||
allowCredentials: [],
|
||||
userVerification: 'required',
|
||||
timeout: 60000,
|
||||
});
|
||||
await saveChallenge(storage, 'Authentication', options.challenge, null);
|
||||
const token = await createAccountPasskeyToken(env, {
|
||||
scope: 'Authentication',
|
||||
challenge: options.challenge,
|
||||
userId: null,
|
||||
rpId,
|
||||
});
|
||||
return jsonResponse({ options, token, object: 'webAuthnLoginAssertionOptions', Object: 'webAuthnLoginAssertionOptions' });
|
||||
}
|
||||
|
||||
export async function assertAccountPasskeyCredential(
|
||||
request: Request,
|
||||
env: Env,
|
||||
storage: StorageService,
|
||||
input: {
|
||||
token: string;
|
||||
deviceResponse: unknown;
|
||||
scope: 'Authentication' | 'UpdateKeySet';
|
||||
expectedUserId?: string | null;
|
||||
}
|
||||
): Promise<{ user: User; credential: AccountPasskeyCredential }> {
|
||||
const payload = await verifyAccountPasskeyToken(env, input.token, input.scope);
|
||||
if (!payload) {
|
||||
throw new Error('Passkey challenge token is invalid or expired');
|
||||
}
|
||||
if (input.expectedUserId !== undefined && payload.userId !== input.expectedUserId) {
|
||||
throw new Error('Passkey challenge token does not match this user');
|
||||
}
|
||||
|
||||
const response = normalizeAuthenticationResponse(input.deviceResponse);
|
||||
if (!response) {
|
||||
throw new Error('Invalid passkey assertion response');
|
||||
}
|
||||
|
||||
const challengeHash = await sha256Base64Url(payload.challenge);
|
||||
const consumed = await storage.consumeAccountPasskeyChallenge(
|
||||
challengeHash,
|
||||
input.scope,
|
||||
payload.userId,
|
||||
Date.now()
|
||||
);
|
||||
if (!consumed) {
|
||||
throw new Error('Passkey challenge has expired or was already used');
|
||||
}
|
||||
|
||||
const credential = await storage.getAccountPasskeyCredentialByCredentialId(response.rawId);
|
||||
if (!credential) {
|
||||
throw new Error('Passkey is not registered for this server');
|
||||
}
|
||||
if (payload.userId && credential.userId !== payload.userId) {
|
||||
throw new Error('Passkey does not belong to this user');
|
||||
}
|
||||
|
||||
const userHandleUserId = userHandleToUserId(response.response.userHandle);
|
||||
const resolvedUserId = payload.userId || userHandleUserId || credential.userId;
|
||||
if (!resolvedUserId || resolvedUserId !== credential.userId) {
|
||||
throw new Error('Passkey user handle does not match this credential');
|
||||
}
|
||||
|
||||
const user = await storage.getUserById(resolvedUserId);
|
||||
if (!user || user.status !== 'active') {
|
||||
throw new Error('Passkey user is not available');
|
||||
}
|
||||
|
||||
const { origins } = getAccountPasskeyRpConfig(request, env);
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: payload.challenge,
|
||||
expectedOrigin: origins,
|
||||
expectedRPID: payload.rpId,
|
||||
credential: toSimpleWebAuthnCredential(credential),
|
||||
requireUserVerification: true,
|
||||
advancedFIDOConfig: { userVerification: 'required' },
|
||||
});
|
||||
if (!verification.verified || !verification.authenticationInfo.userVerified) {
|
||||
throw new Error('Passkey assertion could not be verified');
|
||||
}
|
||||
|
||||
await storage.updateAccountPasskeyCounter(
|
||||
credential.userId,
|
||||
credential.credentialId,
|
||||
verification.authenticationInfo.newCounter,
|
||||
new Date().toISOString()
|
||||
);
|
||||
credential.counter = verification.authenticationInfo.newCounter;
|
||||
return { user, credential };
|
||||
}
|
||||
|
||||
export async function handleGetAccountPasskeyCredentials(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId);
|
||||
return jsonResponse({
|
||||
data: credentials.map(accountPasskeyCredentialToResponse),
|
||||
Data: credentials.map(accountPasskeyCredentialToResponse),
|
||||
object: 'list',
|
||||
Object: 'list',
|
||||
continuationToken: null,
|
||||
ContinuationToken: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleGetAccountPasskeyAttestationOptions(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
|
||||
let stage = 'verify_master_password';
|
||||
try {
|
||||
if (!(await verifyUserSecret(env, user, body))) {
|
||||
return errorResponse('Master password verification failed', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
stage = 'load_existing_credentials';
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId);
|
||||
if (credentials.length >= MAX_ACCOUNT_PASSKEYS) {
|
||||
return errorResponse('Maximum passkey count reached', 400);
|
||||
}
|
||||
|
||||
const { rpId, rpName } = getAccountPasskeyRpConfig(request, env);
|
||||
stage = 'generate_options';
|
||||
const options = await generateRegistrationOptions({
|
||||
rpID: rpId,
|
||||
rpName,
|
||||
userID: Uint8Array.from(userIdToWebAuthnUserId(user.id)),
|
||||
userName: user.email,
|
||||
userDisplayName: user.name || user.email,
|
||||
attestationType: 'none',
|
||||
timeout: 60000,
|
||||
excludeCredentials: credentials.map((credential) => ({
|
||||
id: credential.credentialId,
|
||||
transports: (credential.transports || undefined) as any,
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: 'required',
|
||||
requireResidentKey: true,
|
||||
userVerification: 'required',
|
||||
},
|
||||
});
|
||||
(options as any).extensions = {
|
||||
...((options as any).extensions || {}),
|
||||
prf: {},
|
||||
};
|
||||
stage = 'save_challenge';
|
||||
await saveChallenge(storage, 'CreateCredential', options.challenge, userId);
|
||||
stage = 'create_token';
|
||||
const token = await createAccountPasskeyToken(env, {
|
||||
scope: 'CreateCredential',
|
||||
challenge: options.challenge,
|
||||
userId,
|
||||
rpId,
|
||||
});
|
||||
return jsonResponse({ options, token, object: 'webauthnCredentialCreateOptions', Object: 'webauthnCredentialCreateOptions' });
|
||||
} catch (error) {
|
||||
logAccountPasskeyHandlerError(stage, error, { userId });
|
||||
return errorResponse(`Passkey setup failed while ${passkeySetupStageMessage(stage)}`, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGetAccountPasskeyUpdateAssertionOptions(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
if (!(await verifyUserSecret(env, user, body))) {
|
||||
return errorResponse('Master password verification failed', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
let credentials = await storage.getAccountPasskeyCredentialsByUserId(userId);
|
||||
const requestedId = String(body.credentialId || body.id || '').trim();
|
||||
if (requestedId) {
|
||||
credentials = credentials.filter((credential) => credential.id === requestedId);
|
||||
if (!credentials.length) return errorResponse('Account passkey not found', 404);
|
||||
}
|
||||
if (!credentials.length) return errorResponse('No account passkeys registered', 404);
|
||||
|
||||
const { rpId } = getAccountPasskeyRpConfig(request, env);
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rpId,
|
||||
allowCredentials: credentials.map((credential) => ({
|
||||
id: credential.credentialId,
|
||||
transports: (credential.transports || undefined) as any,
|
||||
})),
|
||||
userVerification: 'required',
|
||||
timeout: 60000,
|
||||
});
|
||||
await saveChallenge(storage, 'UpdateKeySet', options.challenge, userId);
|
||||
const token = await createAccountPasskeyToken(env, {
|
||||
scope: 'UpdateKeySet',
|
||||
challenge: options.challenge,
|
||||
userId,
|
||||
rpId,
|
||||
});
|
||||
return jsonResponse({ options, token, object: 'webAuthnLoginAssertionOptions', Object: 'webAuthnLoginAssertionOptions' });
|
||||
}
|
||||
|
||||
export async function handleCreateAccountPasskeyCredential(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const payload = await verifyAccountPasskeyToken(env, String(body.token || ''), 'CreateCredential');
|
||||
if (!payload || payload.userId !== userId) {
|
||||
return errorResponse('Passkey challenge token is invalid or expired', 400);
|
||||
}
|
||||
|
||||
const challengeHash = await sha256Base64Url(payload.challenge);
|
||||
const consumed = await storage.consumeAccountPasskeyChallenge(challengeHash, 'CreateCredential', userId, Date.now());
|
||||
if (!consumed) {
|
||||
return errorResponse('Passkey challenge has expired or was already used', 400);
|
||||
}
|
||||
|
||||
const currentCount = await storage.countAccountPasskeyCredentialsByUserId(userId);
|
||||
if (currentCount >= MAX_ACCOUNT_PASSKEYS) {
|
||||
return errorResponse('Maximum passkey count reached', 400);
|
||||
}
|
||||
|
||||
let prfKeySet: ReturnType<typeof readPrfKeySet>;
|
||||
try {
|
||||
prfKeySet = readPrfKeySet(body);
|
||||
} catch {
|
||||
return errorResponse('Invalid encrypted passkey key set', 400);
|
||||
}
|
||||
|
||||
const registrationResponse = normalizeRegistrationResponse(body.deviceResponse);
|
||||
if (!registrationResponse) {
|
||||
return errorResponse('Invalid passkey registration response', 400);
|
||||
}
|
||||
|
||||
const { origins } = getAccountPasskeyRpConfig(request, env);
|
||||
let verification: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response: registrationResponse,
|
||||
expectedChallenge: payload.challenge,
|
||||
expectedOrigin: origins,
|
||||
expectedRPID: payload.rpId,
|
||||
requireUserPresence: true,
|
||||
requireUserVerification: true,
|
||||
});
|
||||
} catch {
|
||||
return errorResponse('Passkey registration could not be verified', 400);
|
||||
}
|
||||
if (!verification.verified) {
|
||||
return errorResponse('Passkey registration could not be verified', 400);
|
||||
}
|
||||
|
||||
const existing = await storage.getAccountPasskeyCredentialByCredentialId(verification.registrationInfo.credential.id);
|
||||
if (existing) {
|
||||
return errorResponse('Passkey is already registered', 409);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const supportsPrf = !!body.supportsPrf || hasCompletePrfKeySet(body);
|
||||
const transports = normalizeTransports(registrationResponse.response.transports);
|
||||
const credential: AccountPasskeyCredential = {
|
||||
id: generateUUID(),
|
||||
userId,
|
||||
name: normalizeAccountPasskeyName(body.name),
|
||||
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
|
||||
credentialId: verification.registrationInfo.credential.id,
|
||||
counter: verification.registrationInfo.credential.counter,
|
||||
type: verification.registrationInfo.credentialType || 'public-key',
|
||||
aaGuid: verification.registrationInfo.aaguid || null,
|
||||
transports,
|
||||
encryptedUserKey: prfKeySet.encryptedUserKey,
|
||||
encryptedPublicKey: prfKeySet.encryptedPublicKey,
|
||||
encryptedPrivateKey: prfKeySet.encryptedPrivateKey,
|
||||
supportsPrf,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await storage.saveAccountPasskeyCredential(credential);
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: userId,
|
||||
action: 'account.passkey.create',
|
||||
category: 'security',
|
||||
level: 'info',
|
||||
targetType: 'accountPasskey',
|
||||
targetId: credential.id,
|
||||
metadata: {
|
||||
prfStatus: accountPasskeyPrfStatus(credential),
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
|
||||
return jsonResponse(accountPasskeyCredentialToResponse(credential));
|
||||
}
|
||||
|
||||
export async function handleUpdateAccountPasskeyEncryption(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
|
||||
let prfKeySet: ReturnType<typeof readPrfKeySet>;
|
||||
try {
|
||||
prfKeySet = readPrfKeySet(body);
|
||||
} catch {
|
||||
return errorResponse('Invalid encrypted passkey key set', 400);
|
||||
}
|
||||
if (!prfKeySet.encryptedUserKey || !prfKeySet.encryptedPublicKey || !prfKeySet.encryptedPrivateKey) {
|
||||
return errorResponse('Encrypted passkey key set is required', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
let assertion: Awaited<ReturnType<typeof assertAccountPasskeyCredential>>;
|
||||
try {
|
||||
assertion = await assertAccountPasskeyCredential(request, env, storage, {
|
||||
token: String(body.token || ''),
|
||||
deviceResponse: body.deviceResponse,
|
||||
scope: 'UpdateKeySet',
|
||||
expectedUserId: userId,
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error instanceof Error ? error.message : 'Passkey assertion failed', 400);
|
||||
}
|
||||
|
||||
const updated = await storage.updateAccountPasskeyEncryption(
|
||||
userId,
|
||||
assertion.credential.credentialId,
|
||||
prfKeySet.encryptedUserKey,
|
||||
prfKeySet.encryptedPublicKey,
|
||||
prfKeySet.encryptedPrivateKey
|
||||
);
|
||||
if (!updated) return errorResponse('Passkey not found', 404);
|
||||
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: userId,
|
||||
action: 'account.passkey.encryption.enable',
|
||||
category: 'security',
|
||||
level: 'info',
|
||||
targetType: 'accountPasskey',
|
||||
targetId: assertion.credential.id,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
return jsonResponse({ success: true });
|
||||
}
|
||||
|
||||
export async function handleDeleteAccountPasskeyCredential(request: Request, env: Env, userId: string, credentialId: string, user: User): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
if (!(await verifyUserSecret(env, user, body))) {
|
||||
return errorResponse('Master password verification failed', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const deleted = await storage.deleteAccountPasskeyCredential(userId, credentialId);
|
||||
if (!deleted) return errorResponse('Passkey not found', 404);
|
||||
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: userId,
|
||||
action: 'account.passkey.delete',
|
||||
category: 'security',
|
||||
level: 'info',
|
||||
targetType: 'accountPasskey',
|
||||
targetId: credentialId,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
return jsonResponse({ success: true });
|
||||
}
|
||||
|
||||
export function buildAccountPasskeyTokenUserDecryptionOption(credential: AccountPasskeyCredential) {
|
||||
return buildWebAuthnPrfOption(credential);
|
||||
}
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
buildUserDecryptionOptions,
|
||||
} from '../utils/user-decryption';
|
||||
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
|
||||
import {
|
||||
assertAccountPasskeyCredential,
|
||||
buildAccountPasskeyTokenUserDecryptionOption,
|
||||
} from './account-passkeys';
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||
@@ -423,6 +427,126 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
||||
: baseResponse;
|
||||
|
||||
} else if (grantType === 'webauthn') {
|
||||
const loginIdentifier = clientIdentifier;
|
||||
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
|
||||
if (!loginCheck.allowed) {
|
||||
return identityErrorResponse(
|
||||
`Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`,
|
||||
'TooManyRequests',
|
||||
429
|
||||
);
|
||||
}
|
||||
|
||||
const token = String(body.token || '').trim();
|
||||
let deviceResponse: unknown = body.deviceResponse;
|
||||
if (typeof deviceResponse === 'string') {
|
||||
try {
|
||||
deviceResponse = JSON.parse(deviceResponse);
|
||||
} catch {
|
||||
return identityErrorResponse('Invalid passkey response', 'invalid_request', 400);
|
||||
}
|
||||
}
|
||||
if (!token || !deviceResponse) {
|
||||
return identityErrorResponse('Passkey token and deviceResponse are required', 'invalid_request', 400);
|
||||
}
|
||||
|
||||
let asserted: Awaited<ReturnType<typeof assertAccountPasskeyCredential>>;
|
||||
try {
|
||||
asserted = await assertAccountPasskeyCredential(request, env, storage, {
|
||||
token,
|
||||
deviceResponse,
|
||||
scope: 'Authentication',
|
||||
});
|
||||
} catch (error) {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: null,
|
||||
action: 'auth.passkey.login.failed',
|
||||
category: 'auth',
|
||||
level: 'warn',
|
||||
targetType: 'accountPasskey',
|
||||
targetId: null,
|
||||
metadata: {
|
||||
grantType,
|
||||
reason: error instanceof Error ? error.message : 'assertion_failed',
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
return identityErrorResponse('Passkey is invalid. Try again', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
const { user, credential } = asserted;
|
||||
if (user.status !== 'active') {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo);
|
||||
if (deviceSession) {
|
||||
await storage.upsertDevice(
|
||||
user.id,
|
||||
deviceSession.identifier,
|
||||
deviceInfo.deviceName,
|
||||
deviceInfo.deviceType,
|
||||
deviceSession.sessionStamp
|
||||
);
|
||||
}
|
||||
|
||||
await rateLimit.clearLoginAttempts(loginIdentifier);
|
||||
|
||||
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
||||
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption);
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: user.id,
|
||||
action: 'auth.passkey.login.success',
|
||||
category: 'auth',
|
||||
level: 'info',
|
||||
targetType: 'accountPasskey',
|
||||
targetId: credential.id,
|
||||
metadata: {
|
||||
grantType,
|
||||
webSession: shouldUseWebSession(request),
|
||||
deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier,
|
||||
deviceType: deviceInfo.deviceType,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
|
||||
const response: TokenResponse = {
|
||||
access_token: accessToken,
|
||||
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||
token_type: 'Bearer',
|
||||
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
|
||||
Key: user.key,
|
||||
PrivateKey: user.privateKey,
|
||||
AccountKeys: accountKeys,
|
||||
accountKeys: accountKeys,
|
||||
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: userDecryptionOptions,
|
||||
userDecryptionOptions: userDecryptionOptions,
|
||||
};
|
||||
|
||||
const baseResponse = jsonResponse(response);
|
||||
return shouldUseWebSession(request)
|
||||
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
||||
: baseResponse;
|
||||
|
||||
} else if (grantType === 'client_credentials') {
|
||||
// Login with client credentials
|
||||
const clientId = body.client_id;
|
||||
|
||||
+22
-4
@@ -10,6 +10,7 @@ import {
|
||||
buildUserDecryptionOptions,
|
||||
} from '../utils/user-decryption';
|
||||
import { buildDomainsResponse } from '../services/domain-rules';
|
||||
import { buildWebAuthnPrfOption } from '../utils/account-passkeys';
|
||||
|
||||
// CONTRACT:
|
||||
// /api/sync reuses cipherToResponse() as the single cipher response shaper.
|
||||
@@ -20,13 +21,14 @@ function buildSyncCacheRequest(
|
||||
request: Request,
|
||||
userId: string,
|
||||
revisionDate: string,
|
||||
accountPasskeyCacheTag: string,
|
||||
excludeDomains: boolean,
|
||||
excludeSends: boolean,
|
||||
preserveRepairableUris: boolean
|
||||
): Request {
|
||||
const url = new URL(request.url);
|
||||
const cacheUrl = new URL(
|
||||
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}/${excludeSends ? '1' : '0'}/${preserveRepairableUris ? '1' : '0'}`,
|
||||
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${encodeURIComponent(accountPasskeyCacheTag)}/${excludeDomains ? '1' : '0'}/${excludeSends ? '1' : '0'}/${preserveRepairableUris ? '1' : '0'}`,
|
||||
url.origin
|
||||
);
|
||||
return new Request(cacheUrl.toString(), { method: 'GET' });
|
||||
@@ -57,8 +59,19 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
return errorResponse('User not found', 404);
|
||||
}
|
||||
|
||||
const revisionDate = await storage.getRevisionDate(userId);
|
||||
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains, excludeSends, preserveRepairableUris);
|
||||
const [revisionDate, accountPasskeys] = await Promise.all([
|
||||
storage.getRevisionDate(userId),
|
||||
storage.getAccountPasskeyCredentialsByUserId(userId),
|
||||
]);
|
||||
const accountPasskeyCacheTag = accountPasskeys
|
||||
.map((credential) => [
|
||||
credential.id,
|
||||
credential.updatedAt,
|
||||
credential.supportsPrf ? '1' : '0',
|
||||
credential.encryptedUserKey && credential.encryptedPublicKey && credential.encryptedPrivateKey ? '1' : '0',
|
||||
].join(':'))
|
||||
.join(',');
|
||||
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, accountPasskeyCacheTag, excludeDomains, excludeSends, preserveRepairableUris);
|
||||
const cachedResponse = await readSyncCache(cacheRequest);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
@@ -72,7 +85,10 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
excludeDomains ? Promise.resolve(null) : storage.getUserDomainSettings(userId),
|
||||
]);
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||
const webAuthnPrfOptions = accountPasskeys
|
||||
.map(buildWebAuthnPrfOption)
|
||||
.filter((option): option is NonNullable<typeof option> => !!option);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null);
|
||||
|
||||
const profile: ProfileResponse = {
|
||||
id: user.id,
|
||||
@@ -138,6 +154,8 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
|
||||
TrustedDeviceOption: null,
|
||||
KeyConnectorOption: null,
|
||||
WebAuthnPrfOption: webAuthnPrfOptions[0] || null,
|
||||
WebAuthnPrfOptions: webAuthnPrfOptions,
|
||||
Object: 'userDecryption',
|
||||
},
|
||||
UserDecryptionOptions: userDecryptionOptions,
|
||||
|
||||
@@ -66,6 +66,14 @@ import {
|
||||
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
||||
import { handleAdminRoute } from './router-admin';
|
||||
import { handleGetDomains, handleUpdateDomains } from './handlers/domains';
|
||||
import {
|
||||
handleCreateAccountPasskeyCredential,
|
||||
handleDeleteAccountPasskeyCredential,
|
||||
handleGetAccountPasskeyAttestationOptions,
|
||||
handleGetAccountPasskeyCredentials,
|
||||
handleGetAccountPasskeyUpdateAssertionOptions,
|
||||
handleUpdateAccountPasskeyEncryption,
|
||||
} from './handlers/account-passkeys';
|
||||
|
||||
export async function handleAuthenticatedRoute(
|
||||
request: Request,
|
||||
@@ -131,6 +139,28 @@ export async function handleAuthenticatedRoute(
|
||||
return handleRotateApiKey(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/webauthn' || path === '/webauthn') {
|
||||
if (method === 'GET') return handleGetAccountPasskeyCredentials(request, env, userId);
|
||||
if (method === 'POST') return handleCreateAccountPasskeyCredential(request, env, userId);
|
||||
if (method === 'PUT') return handleUpdateAccountPasskeyEncryption(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if ((path === '/api/webauthn/attestation-options' || path === '/webauthn/attestation-options') && method === 'POST') {
|
||||
return handleGetAccountPasskeyAttestationOptions(request, env, userId, currentUser);
|
||||
}
|
||||
|
||||
if ((path === '/api/webauthn/assertion-options' || path === '/webauthn/assertion-options') && method === 'POST') {
|
||||
return handleGetAccountPasskeyUpdateAssertionOptions(request, env, userId, currentUser);
|
||||
}
|
||||
|
||||
const accountPasskeyDeleteMatch =
|
||||
path.match(/^\/api\/webauthn\/([^/]+)\/delete$/i) ||
|
||||
path.match(/^\/webauthn\/([^/]+)\/delete$/i);
|
||||
if (accountPasskeyDeleteMatch && method === 'POST') {
|
||||
return handleDeleteAccountPasskeyCredential(request, env, userId, accountPasskeyDeleteMatch[1], currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/sync' && method === 'GET') {
|
||||
return handleSync(request, env, userId);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from './handlers/sends';
|
||||
import { handleKnownDevice } from './handlers/devices';
|
||||
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
|
||||
import { handleGetAccountPasskeyAssertionOptions } from './handlers/account-passkeys';
|
||||
import {
|
||||
handleRegister,
|
||||
handleGetPasswordHint,
|
||||
@@ -422,6 +423,12 @@ export async function handlePublicRoute(
|
||||
return handlePrelogin(request, env);
|
||||
}
|
||||
|
||||
if (path === '/identity/accounts/webauthn/assertion-options' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleGetAccountPasskeyAssertionOptions(request, env);
|
||||
}
|
||||
|
||||
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
|
||||
return handleRecoverTwoFactor(request, env);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ const ALLOWED_METADATA_KEYS = new Set([
|
||||
'skippedReason',
|
||||
'replaceExisting',
|
||||
'provider',
|
||||
'prfStatus',
|
||||
'fileName',
|
||||
'fileBytes',
|
||||
'bytes',
|
||||
|
||||
@@ -67,6 +67,7 @@ export interface BackupPayload {
|
||||
folders: SqlRow[];
|
||||
ciphers: SqlRow[];
|
||||
attachments: SqlRow[];
|
||||
webauthn_credentials?: SqlRow[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -300,6 +301,7 @@ export function validateBackupPayloadContents(
|
||||
const folderRows = ensureRowArray(payload.db.folders, 'folders');
|
||||
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
|
||||
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
|
||||
const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials');
|
||||
const externalAttachmentKeys = new Set<string>(
|
||||
options.allowExternalAttachmentBlobs
|
||||
? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
|
||||
@@ -372,6 +374,22 @@ export function validateBackupPayloadContents(
|
||||
throw new Error(`Backup archive is missing required file: attachments/${cipherId}/${id}.bin`);
|
||||
}
|
||||
}
|
||||
|
||||
const accountPasskeyIds = new Set<string>();
|
||||
const accountPasskeyCredentialIds = new Set<string>();
|
||||
for (const row of accountPasskeyRows) {
|
||||
const id = String(row.id || '').trim();
|
||||
const userId = String(row.user_id || '').trim();
|
||||
const credentialId = String(row.credential_id || '').trim();
|
||||
const publicKey = String(row.public_key || '').trim();
|
||||
if (!id || !userIds.has(userId) || !credentialId || !publicKey) {
|
||||
throw new Error('Backup archive contains an invalid account passkey row');
|
||||
}
|
||||
if (accountPasskeyIds.has(id)) throw new Error(`Backup archive contains duplicate account passkey id: ${id}`);
|
||||
if (accountPasskeyCredentialIds.has(credentialId)) throw new Error(`Backup archive contains duplicate account passkey credential id: ${credentialId}`);
|
||||
accountPasskeyIds.add(id);
|
||||
accountPasskeyCredentialIds.add(credentialId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildBackupArchive(
|
||||
@@ -390,7 +408,7 @@ export async function buildBackupArchive(
|
||||
includeAttachments,
|
||||
});
|
||||
const encoder = new TextEncoder();
|
||||
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([
|
||||
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows] = await Promise.all([
|
||||
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
||||
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'),
|
||||
@@ -398,6 +416,7 @@ export async function buildBackupArchive(
|
||||
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
|
||||
queryRows(env.DB, 'SELECT id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
|
||||
]);
|
||||
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
|
||||
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
|
||||
@@ -425,6 +444,7 @@ export async function buildBackupArchive(
|
||||
folders: folderRows.length,
|
||||
ciphers: cipherRows.length,
|
||||
attachments: exportedAttachmentRows.length,
|
||||
webauthn_credentials: accountPasskeyRows.length,
|
||||
},
|
||||
includes: {
|
||||
attachments: includeAttachments,
|
||||
@@ -447,6 +467,7 @@ export async function buildBackupArchive(
|
||||
folders: folderRows,
|
||||
ciphers: cipherRows,
|
||||
attachments: exportedAttachmentRows,
|
||||
webauthn_credentials: accountPasskeyRows,
|
||||
}, null, BACKUP_JSON_INDENT)),
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ type BackupTableName =
|
||||
| 'users'
|
||||
| 'domain_settings'
|
||||
| 'user_revisions'
|
||||
| 'webauthn_credentials'
|
||||
| 'folders'
|
||||
| 'ciphers'
|
||||
| 'attachments';
|
||||
@@ -33,6 +34,7 @@ const BACKUP_TABLES: BackupTableName[] = [
|
||||
'users',
|
||||
'domain_settings',
|
||||
'user_revisions',
|
||||
'webauthn_credentials',
|
||||
'folders',
|
||||
'ciphers',
|
||||
'attachments',
|
||||
@@ -49,6 +51,7 @@ export interface BackupImportResultBody {
|
||||
users: number;
|
||||
domainSettings: number;
|
||||
userRevisions: number;
|
||||
webauthnCredentials: number;
|
||||
folders: number;
|
||||
ciphers: number;
|
||||
attachments: number;
|
||||
@@ -168,6 +171,7 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[]
|
||||
'DELETE FROM attachments',
|
||||
'DELETE FROM ciphers',
|
||||
'DELETE FROM folders',
|
||||
'DELETE FROM webauthn_credentials',
|
||||
'DELETE FROM domain_settings',
|
||||
'DELETE FROM user_revisions',
|
||||
'DELETE FROM users',
|
||||
@@ -292,6 +296,7 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
|
||||
})),
|
||||
domain_settings: cloneRows(payload.domain_settings || []),
|
||||
user_revisions: cloneRows(payload.user_revisions || []),
|
||||
webauthn_credentials: cloneRows(payload.webauthn_credentials || []),
|
||||
folders: cloneRows(payload.folders || []),
|
||||
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
||||
...row,
|
||||
@@ -629,6 +634,16 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
||||
true
|
||||
)
|
||||
);
|
||||
await runInsertBatch(
|
||||
db,
|
||||
tableName('webauthn_credentials'),
|
||||
buildInsertStatements(
|
||||
db,
|
||||
tableName('webauthn_credentials'),
|
||||
['id', 'user_id', 'name', 'public_key', 'credential_id', 'counter', 'type', 'aa_guid', 'transports', 'encrypted_user_key', 'encrypted_public_key', 'encrypted_private_key', 'supports_prf', 'created_at', 'updated_at'],
|
||||
payload.webauthn_credentials || []
|
||||
)
|
||||
);
|
||||
await runInsertBatch(
|
||||
db,
|
||||
tableName('folders'),
|
||||
@@ -697,6 +712,7 @@ export async function importBackupArchiveBytes(
|
||||
users: (db.users || []).length,
|
||||
domain_settings: (db.domain_settings || []).length,
|
||||
user_revisions: (db.user_revisions || []).length,
|
||||
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: (db.attachments || []).length,
|
||||
@@ -719,6 +735,7 @@ export async function importBackupArchiveBytes(
|
||||
users: (db.users || []).length,
|
||||
domain_settings: (db.domain_settings || []).length,
|
||||
user_revisions: (db.user_revisions || []).length,
|
||||
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: restored.restoredAttachments.length,
|
||||
@@ -759,6 +776,7 @@ export async function importBackupArchiveBytes(
|
||||
users: (db.users || []).length,
|
||||
domainSettings: (db.domain_settings || []).length,
|
||||
userRevisions: (db.user_revisions || []).length,
|
||||
webauthnCredentials: (db.webauthn_credentials || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: restored.restoredAttachments.length,
|
||||
@@ -835,6 +853,7 @@ export async function importRemoteBackupArchiveBytes(
|
||||
users: (db.users || []).length,
|
||||
domain_settings: (db.domain_settings || []).length,
|
||||
user_revisions: (db.user_revisions || []).length,
|
||||
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: (db.attachments || []).length,
|
||||
@@ -857,6 +876,7 @@ export async function importRemoteBackupArchiveBytes(
|
||||
users: (db.users || []).length,
|
||||
domain_settings: (db.domain_settings || []).length,
|
||||
user_revisions: (db.user_revisions || []).length,
|
||||
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: restored.restoredAttachments.length,
|
||||
@@ -903,6 +923,7 @@ export async function importRemoteBackupArchiveBytes(
|
||||
users: (db.users || []).length,
|
||||
domainSettings: (db.domain_settings || []).length,
|
||||
userRevisions: (db.user_revisions || []).length,
|
||||
webauthnCredentials: (db.webauthn_credentials || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: restored.restoredAttachments.length,
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import type { AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential } from '../types';
|
||||
|
||||
type SafeBindFn = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
||||
|
||||
let accountPasskeySchemaReady = false;
|
||||
|
||||
const ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS = [
|
||||
{ name: 'id', sql: 'id TEXT' },
|
||||
{ name: 'user_id', sql: "user_id TEXT NOT NULL DEFAULT ''" },
|
||||
{ name: 'name', sql: "name TEXT NOT NULL DEFAULT 'Account passkey'" },
|
||||
{ name: 'public_key', sql: "public_key TEXT NOT NULL DEFAULT ''" },
|
||||
{ name: 'credential_id', sql: "credential_id TEXT NOT NULL DEFAULT ''" },
|
||||
{ name: 'counter', sql: 'counter INTEGER NOT NULL DEFAULT 0' },
|
||||
{ name: 'type', sql: 'type TEXT' },
|
||||
{ name: 'aa_guid', sql: 'aa_guid TEXT' },
|
||||
{ name: 'transports', sql: 'transports TEXT' },
|
||||
{ name: 'encrypted_user_key', sql: 'encrypted_user_key TEXT' },
|
||||
{ name: 'encrypted_public_key', sql: 'encrypted_public_key TEXT' },
|
||||
{ name: 'encrypted_private_key', sql: 'encrypted_private_key TEXT' },
|
||||
{ name: 'supports_prf', sql: 'supports_prf INTEGER NOT NULL DEFAULT 0' },
|
||||
{ name: 'created_at', sql: "created_at TEXT NOT NULL DEFAULT ''" },
|
||||
{ name: 'updated_at', sql: "updated_at TEXT NOT NULL DEFAULT ''" },
|
||||
] as const;
|
||||
|
||||
const ACCOUNT_PASSKEY_CHALLENGE_COLUMNS = [
|
||||
'challenge_hash',
|
||||
'scope',
|
||||
'user_id',
|
||||
'expires_at',
|
||||
'used_at',
|
||||
'created_at',
|
||||
] as const;
|
||||
|
||||
async function tableColumns(db: D1Database, tableName: 'webauthn_credentials' | 'webauthn_challenges'): Promise<Set<string>> {
|
||||
const result = await db.prepare(`PRAGMA table_info(${tableName})`).all<{ name: string }>();
|
||||
return new Set((result.results || []).map((row) => String(row.name || '').trim()).filter(Boolean));
|
||||
}
|
||||
|
||||
async function ensureAccountPasskeySchema(db: D1Database): Promise<void> {
|
||||
if (accountPasskeySchemaReady) return;
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
|
||||
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)'
|
||||
)
|
||||
.run();
|
||||
let credentialColumns = await tableColumns(db, 'webauthn_credentials');
|
||||
for (const column of ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS) {
|
||||
if (!credentialColumns.has(column.name)) {
|
||||
await db.prepare(`ALTER TABLE webauthn_credentials ADD COLUMN ${column.sql}`).run();
|
||||
}
|
||||
}
|
||||
credentialColumns = await tableColumns(db, 'webauthn_credentials');
|
||||
if (!credentialColumns.has('credential_id')) {
|
||||
throw new Error('webauthn_credentials schema is missing credential_id');
|
||||
}
|
||||
await db.prepare('CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_id ON webauthn_credentials(id)').run();
|
||||
await db.prepare('CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id ON webauthn_credentials(credential_id)').run();
|
||||
await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)').run();
|
||||
await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated ON webauthn_credentials(user_id, updated_at)').run();
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS webauthn_challenges (' +
|
||||
'challenge_hash TEXT PRIMARY KEY, scope TEXT NOT NULL, user_id TEXT, expires_at INTEGER NOT NULL, used_at INTEGER, created_at INTEGER NOT NULL)'
|
||||
)
|
||||
.run();
|
||||
const challengeColumns = await tableColumns(db, 'webauthn_challenges');
|
||||
const challengeSchemaComplete = ACCOUNT_PASSKEY_CHALLENGE_COLUMNS.every((column) => challengeColumns.has(column));
|
||||
if (!challengeSchemaComplete) {
|
||||
await db.prepare('DROP TABLE IF EXISTS webauthn_challenges').run();
|
||||
await db
|
||||
.prepare(
|
||||
'CREATE TABLE webauthn_challenges (' +
|
||||
'challenge_hash TEXT PRIMARY KEY, scope TEXT NOT NULL, user_id TEXT, expires_at INTEGER NOT NULL, used_at INTEGER, created_at INTEGER NOT NULL)'
|
||||
)
|
||||
.run();
|
||||
}
|
||||
await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at)').run();
|
||||
await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user_scope ON webauthn_challenges(user_id, scope)').run();
|
||||
|
||||
accountPasskeySchemaReady = true;
|
||||
}
|
||||
|
||||
function parseTransports(value: string | null): string[] | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
return parsed.map((item) => String(item || '').trim()).filter(Boolean);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mapCredentialRow(row: {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
public_key: string;
|
||||
credential_id: string;
|
||||
counter: number;
|
||||
type: string | null;
|
||||
aa_guid: string | null;
|
||||
transports: string | null;
|
||||
encrypted_user_key: string | null;
|
||||
encrypted_public_key: string | null;
|
||||
encrypted_private_key: string | null;
|
||||
supports_prf: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}): AccountPasskeyCredential {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
name: row.name,
|
||||
publicKey: row.public_key,
|
||||
credentialId: row.credential_id,
|
||||
counter: Number(row.counter || 0),
|
||||
type: row.type ?? null,
|
||||
aaGuid: row.aa_guid ?? null,
|
||||
transports: parseTransports(row.transports),
|
||||
encryptedUserKey: row.encrypted_user_key ?? null,
|
||||
encryptedPublicKey: row.encrypted_public_key ?? null,
|
||||
encryptedPrivateKey: row.encrypted_private_key ?? null,
|
||||
supportsPrf: !!row.supports_prf,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function mapChallengeRow(row: {
|
||||
challenge_hash: string;
|
||||
scope: AccountPasskeyChallengeScope;
|
||||
user_id: string | null;
|
||||
expires_at: number;
|
||||
used_at: number | null;
|
||||
created_at: number;
|
||||
}): AccountPasskeyChallenge {
|
||||
return {
|
||||
challengeHash: row.challenge_hash,
|
||||
scope: row.scope,
|
||||
userId: row.user_id ?? null,
|
||||
expiresAt: Number(row.expires_at || 0),
|
||||
usedAt: row.used_at == null ? null : Number(row.used_at),
|
||||
createdAt: Number(row.created_at || 0),
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveAccountPasskeyCredential(
|
||||
db: D1Database,
|
||||
safeBind: SafeBindFn,
|
||||
credential: AccountPasskeyCredential
|
||||
): Promise<void> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
await safeBind(
|
||||
db.prepare(
|
||||
'INSERT INTO webauthn_credentials(' +
|
||||
'id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, ' +
|
||||
'encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at' +
|
||||
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||
'name=excluded.name, public_key=excluded.public_key, credential_id=excluded.credential_id, counter=excluded.counter, ' +
|
||||
'type=excluded.type, aa_guid=excluded.aa_guid, transports=excluded.transports, encrypted_user_key=excluded.encrypted_user_key, ' +
|
||||
'encrypted_public_key=excluded.encrypted_public_key, encrypted_private_key=excluded.encrypted_private_key, supports_prf=excluded.supports_prf, updated_at=excluded.updated_at'
|
||||
),
|
||||
credential.id,
|
||||
credential.userId,
|
||||
credential.name,
|
||||
credential.publicKey,
|
||||
credential.credentialId,
|
||||
credential.counter,
|
||||
credential.type,
|
||||
credential.aaGuid,
|
||||
credential.transports ? JSON.stringify(credential.transports) : null,
|
||||
credential.encryptedUserKey,
|
||||
credential.encryptedPublicKey,
|
||||
credential.encryptedPrivateKey,
|
||||
credential.supportsPrf ? 1 : 0,
|
||||
credential.createdAt,
|
||||
credential.updatedAt
|
||||
).run();
|
||||
}
|
||||
|
||||
export async function listAccountPasskeyCredentialsByUserId(
|
||||
db: D1Database,
|
||||
userId: string
|
||||
): Promise<AccountPasskeyCredential[]> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const rows = await db
|
||||
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at ASC')
|
||||
.bind(userId)
|
||||
.all<any>();
|
||||
return (rows.results || []).map(mapCredentialRow);
|
||||
}
|
||||
|
||||
export async function getAccountPasskeyCredentialById(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
id: string
|
||||
): Promise<AccountPasskeyCredential | null> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const row = await db
|
||||
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? AND id = ? LIMIT 1')
|
||||
.bind(userId, id)
|
||||
.first<any>();
|
||||
return row ? mapCredentialRow(row) : null;
|
||||
}
|
||||
|
||||
export async function getAccountPasskeyCredentialByCredentialId(
|
||||
db: D1Database,
|
||||
credentialId: string
|
||||
): Promise<AccountPasskeyCredential | null> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const row = await db
|
||||
.prepare('SELECT * FROM webauthn_credentials WHERE credential_id = ? LIMIT 1')
|
||||
.bind(credentialId)
|
||||
.first<any>();
|
||||
return row ? mapCredentialRow(row) : null;
|
||||
}
|
||||
|
||||
export async function countAccountPasskeyCredentialsByUserId(
|
||||
db: D1Database,
|
||||
userId: string
|
||||
): Promise<number> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const row = await db
|
||||
.prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ?')
|
||||
.bind(userId)
|
||||
.first<{ count: number }>();
|
||||
return Number(row?.count || 0);
|
||||
}
|
||||
|
||||
export async function updateAccountPasskeyCounter(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
credentialId: string,
|
||||
counter: number,
|
||||
updatedAt: string
|
||||
): Promise<void> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
await db
|
||||
.prepare('UPDATE webauthn_credentials SET counter = ?, updated_at = ? WHERE user_id = ? AND credential_id = ?')
|
||||
.bind(counter, updatedAt, userId, credentialId)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function updateAccountPasskeyEncryption(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
credentialId: string,
|
||||
encryptedUserKey: string,
|
||||
encryptedPublicKey: string,
|
||||
encryptedPrivateKey: string,
|
||||
updatedAt: string
|
||||
): Promise<boolean> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const result = await db
|
||||
.prepare(
|
||||
'UPDATE webauthn_credentials SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, supports_prf = 1, updated_at = ? ' +
|
||||
'WHERE user_id = ? AND credential_id = ?'
|
||||
)
|
||||
.bind(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey, updatedAt, userId, credentialId)
|
||||
.run();
|
||||
return Number(result.meta.changes || 0) > 0;
|
||||
}
|
||||
|
||||
export async function deleteAccountPasskeyCredential(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
id: string
|
||||
): Promise<boolean> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const result = await db
|
||||
.prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ?')
|
||||
.bind(userId, id)
|
||||
.run();
|
||||
return Number(result.meta.changes || 0) > 0;
|
||||
}
|
||||
|
||||
export async function saveAccountPasskeyChallenge(
|
||||
db: D1Database,
|
||||
challenge: AccountPasskeyChallenge
|
||||
): Promise<void> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
await db.prepare('DELETE FROM webauthn_challenges WHERE expires_at < ? OR used_at IS NOT NULL').bind(Date.now()).run();
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO webauthn_challenges(challenge_hash, scope, user_id, expires_at, used_at, created_at) VALUES(?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(challenge_hash) DO UPDATE SET scope=excluded.scope, user_id=excluded.user_id, expires_at=excluded.expires_at, used_at=excluded.used_at, created_at=excluded.created_at'
|
||||
)
|
||||
.bind(
|
||||
challenge.challengeHash,
|
||||
challenge.scope,
|
||||
challenge.userId,
|
||||
challenge.expiresAt,
|
||||
challenge.usedAt,
|
||||
challenge.createdAt
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function consumeAccountPasskeyChallenge(
|
||||
db: D1Database,
|
||||
challengeHash: string,
|
||||
scope: AccountPasskeyChallengeScope,
|
||||
userId: string | null,
|
||||
nowMs: number
|
||||
): Promise<AccountPasskeyChallenge | null> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const row = await db
|
||||
.prepare('SELECT * FROM webauthn_challenges WHERE challenge_hash = ? AND scope = ? LIMIT 1')
|
||||
.bind(challengeHash, scope)
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
const challenge = mapChallengeRow(row);
|
||||
if (challenge.usedAt != null || challenge.expiresAt < nowMs) return null;
|
||||
if (userId !== null && challenge.userId !== userId) return null;
|
||||
if (userId === null && challenge.userId !== null) return null;
|
||||
|
||||
const result = await db
|
||||
.prepare('UPDATE webauthn_challenges SET used_at = ? WHERE challenge_hash = ? AND used_at IS NULL')
|
||||
.bind(nowMs, challengeHash)
|
||||
.run();
|
||||
if (Number(result.meta.changes || 0) <= 0) return null;
|
||||
return { ...challenge, usedAt: nowMs };
|
||||
}
|
||||
@@ -114,6 +114,20 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
|
||||
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id ON webauthn_credentials(credential_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated ON webauthn_credentials(user_id, updated_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS webauthn_challenges (' +
|
||||
'challenge_hash TEXT PRIMARY KEY, scope TEXT NOT NULL, user_id TEXT, expires_at INTEGER NOT NULL, used_at INTEGER, created_at INTEGER NOT NULL)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user_scope ON webauthn_challenges(user_id, scope)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
|
||||
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
|
||||
|
||||
|
||||
+96
-3
@@ -1,4 +1,4 @@
|
||||
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain } from '../types';
|
||||
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain, AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential } from '../types';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { ensureStorageSchema } from './storage-schema';
|
||||
import {
|
||||
@@ -115,6 +115,18 @@ import {
|
||||
getUserDomainSettings as getStoredUserDomainSettings,
|
||||
saveUserDomainSettings as saveStoredUserDomainSettings,
|
||||
} from './storage-domain-rules-repo';
|
||||
import {
|
||||
consumeAccountPasskeyChallenge as consumeStoredAccountPasskeyChallenge,
|
||||
countAccountPasskeyCredentialsByUserId as countStoredAccountPasskeyCredentialsByUserId,
|
||||
deleteAccountPasskeyCredential as deleteStoredAccountPasskeyCredential,
|
||||
getAccountPasskeyCredentialByCredentialId as findStoredAccountPasskeyCredentialByCredentialId,
|
||||
getAccountPasskeyCredentialById as findStoredAccountPasskeyCredentialById,
|
||||
listAccountPasskeyCredentialsByUserId as listStoredAccountPasskeyCredentialsByUserId,
|
||||
saveAccountPasskeyChallenge as saveStoredAccountPasskeyChallenge,
|
||||
saveAccountPasskeyCredential as saveStoredAccountPasskeyCredential,
|
||||
updateAccountPasskeyCounter as updateStoredAccountPasskeyCounter,
|
||||
updateAccountPasskeyEncryption as updateStoredAccountPasskeyEncryption,
|
||||
} from './storage-account-passkey-repo';
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||
@@ -122,7 +134,8 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
|
||||
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
|
||||
// differs from config.schema.version.
|
||||
const STORAGE_SCHEMA_VERSION = '2026-05-14-lightweight-audit-logs';
|
||||
const STORAGE_SCHEMA_VERSION = '2026-06-09-account-passkeys';
|
||||
const REQUIRED_ACCOUNT_PASSKEY_TABLES = ['webauthn_credentials', 'webauthn_challenges'] as const;
|
||||
|
||||
// D1-backed storage.
|
||||
// Contract:
|
||||
@@ -153,6 +166,16 @@ export class StorageService {
|
||||
return stmt.bind(...values.map(v => v === undefined ? null : v));
|
||||
}
|
||||
|
||||
private async hasAccountPasskeyTables(): Promise<boolean> {
|
||||
const placeholders = REQUIRED_ACCOUNT_PASSKEY_TABLES.map(() => '?').join(', ');
|
||||
const result = await this.db
|
||||
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${placeholders})`)
|
||||
.bind(...REQUIRED_ACCOUNT_PASSKEY_TABLES)
|
||||
.all<{ name: string }>();
|
||||
const found = new Set((result.results || []).map((row) => row.name));
|
||||
return REQUIRED_ACCOUNT_PASSKEY_TABLES.every((table) => found.has(table));
|
||||
}
|
||||
|
||||
private sqlChunkSize(fixedBindCount: number): number {
|
||||
return Math.max(
|
||||
1,
|
||||
@@ -196,7 +219,10 @@ export class StorageService {
|
||||
|
||||
await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
|
||||
const schemaVersion = await getStoredConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY);
|
||||
if (schemaVersion !== STORAGE_SCHEMA_VERSION) {
|
||||
const schemaMissingRequiredTables = schemaVersion === STORAGE_SCHEMA_VERSION
|
||||
? !(await this.hasAccountPasskeyTables())
|
||||
: true;
|
||||
if (schemaVersion !== STORAGE_SCHEMA_VERSION || schemaMissingRequiredTables) {
|
||||
await ensureStorageSchema(this.db);
|
||||
await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION);
|
||||
}
|
||||
@@ -323,6 +349,73 @@ export class StorageService {
|
||||
await this.updateRevisionDate(userId);
|
||||
}
|
||||
|
||||
// --- Account passkeys / WebAuthn login credentials ---
|
||||
|
||||
async saveAccountPasskeyCredential(credential: AccountPasskeyCredential): Promise<void> {
|
||||
await saveStoredAccountPasskeyCredential(this.db, this.safeBind.bind(this), credential);
|
||||
}
|
||||
|
||||
async getAccountPasskeyCredentialsByUserId(userId: string): Promise<AccountPasskeyCredential[]> {
|
||||
return listStoredAccountPasskeyCredentialsByUserId(this.db, userId);
|
||||
}
|
||||
|
||||
async getAccountPasskeyCredentialById(userId: string, id: string): Promise<AccountPasskeyCredential | null> {
|
||||
return findStoredAccountPasskeyCredentialById(this.db, userId, id);
|
||||
}
|
||||
|
||||
async getAccountPasskeyCredentialByCredentialId(credentialId: string): Promise<AccountPasskeyCredential | null> {
|
||||
return findStoredAccountPasskeyCredentialByCredentialId(this.db, credentialId);
|
||||
}
|
||||
|
||||
async countAccountPasskeyCredentialsByUserId(userId: string): Promise<number> {
|
||||
return countStoredAccountPasskeyCredentialsByUserId(this.db, userId);
|
||||
}
|
||||
|
||||
async updateAccountPasskeyCounter(
|
||||
userId: string,
|
||||
credentialId: string,
|
||||
counter: number,
|
||||
updatedAt: string = new Date().toISOString()
|
||||
): Promise<void> {
|
||||
await updateStoredAccountPasskeyCounter(this.db, userId, credentialId, counter, updatedAt);
|
||||
}
|
||||
|
||||
async updateAccountPasskeyEncryption(
|
||||
userId: string,
|
||||
credentialId: string,
|
||||
encryptedUserKey: string,
|
||||
encryptedPublicKey: string,
|
||||
encryptedPrivateKey: string,
|
||||
updatedAt: string = new Date().toISOString()
|
||||
): Promise<boolean> {
|
||||
return updateStoredAccountPasskeyEncryption(
|
||||
this.db,
|
||||
userId,
|
||||
credentialId,
|
||||
encryptedUserKey,
|
||||
encryptedPublicKey,
|
||||
encryptedPrivateKey,
|
||||
updatedAt
|
||||
);
|
||||
}
|
||||
|
||||
async deleteAccountPasskeyCredential(userId: string, id: string): Promise<boolean> {
|
||||
return deleteStoredAccountPasskeyCredential(this.db, userId, id);
|
||||
}
|
||||
|
||||
async saveAccountPasskeyChallenge(challenge: AccountPasskeyChallenge): Promise<void> {
|
||||
await saveStoredAccountPasskeyChallenge(this.db, challenge);
|
||||
}
|
||||
|
||||
async consumeAccountPasskeyChallenge(
|
||||
challengeHash: string,
|
||||
scope: AccountPasskeyChallengeScope,
|
||||
userId: string | null,
|
||||
nowMs: number = Date.now()
|
||||
): Promise<AccountPasskeyChallenge | null> {
|
||||
return consumeStoredAccountPasskeyChallenge(this.db, challengeHash, scope, userId, nowMs);
|
||||
}
|
||||
|
||||
// --- Ciphers ---
|
||||
|
||||
async getCipher(id: string): Promise<Cipher | null> {
|
||||
|
||||
+45
-1
@@ -11,6 +11,9 @@ export interface Env {
|
||||
// Optional fallback for attachment/send file storage (no credit card required).
|
||||
ATTACHMENTS_KV?: KVNamespace;
|
||||
JWT_SECRET: string;
|
||||
WEBAUTHN_RP_ID?: string;
|
||||
WEBAUTHN_RP_NAME?: string;
|
||||
WEBAUTHN_ALLOWED_ORIGINS?: string;
|
||||
}
|
||||
|
||||
export type UserRole = 'admin' | 'user';
|
||||
@@ -234,6 +237,37 @@ export interface Device {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type AccountPasskeyPrfStatus = 0 | 1 | 2;
|
||||
|
||||
export interface AccountPasskeyCredential {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
publicKey: string;
|
||||
credentialId: string;
|
||||
counter: number;
|
||||
type: string | null;
|
||||
aaGuid: string | null;
|
||||
transports: string[] | null;
|
||||
encryptedUserKey: string | null;
|
||||
encryptedPublicKey: string | null;
|
||||
encryptedPrivateKey: string | null;
|
||||
supportsPrf: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type AccountPasskeyChallengeScope = 'Authentication' | 'CreateCredential' | 'UpdateKeySet';
|
||||
|
||||
export interface AccountPasskeyChallenge {
|
||||
challengeHash: string;
|
||||
scope: AccountPasskeyChallengeScope;
|
||||
userId: string | null;
|
||||
expiresAt: number;
|
||||
usedAt: number | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface DevicePendingAuthRequest {
|
||||
id: string;
|
||||
creationDate: string;
|
||||
@@ -372,6 +406,14 @@ export interface MasterPasswordUnlock {
|
||||
Object: string;
|
||||
}
|
||||
|
||||
export interface WebAuthnPrfDecryptionOption {
|
||||
EncryptedPrivateKey: string;
|
||||
EncryptedUserKey: string;
|
||||
CredentialId: string;
|
||||
Transports: string[];
|
||||
Object?: string;
|
||||
}
|
||||
|
||||
export interface UserDecryptionOptions {
|
||||
HasMasterPassword: boolean;
|
||||
Object: string;
|
||||
@@ -379,6 +421,7 @@ export interface UserDecryptionOptions {
|
||||
MasterPasswordUnlock: MasterPasswordUnlock;
|
||||
TrustedDeviceOption: null;
|
||||
KeyConnectorOption: null;
|
||||
WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null;
|
||||
}
|
||||
|
||||
// API Response types
|
||||
@@ -498,7 +541,8 @@ export interface SyncResponse {
|
||||
MasterPasswordUnlock: MasterPasswordUnlock | null;
|
||||
TrustedDeviceOption?: null;
|
||||
KeyConnectorOption?: null;
|
||||
WebAuthnPrfOption?: null;
|
||||
WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null;
|
||||
WebAuthnPrfOptions?: WebAuthnPrfDecryptionOption[];
|
||||
Object?: string;
|
||||
} | null;
|
||||
// PascalCase for desktop/browser clients
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
AuthenticatorTransportFuture,
|
||||
RegistrationResponseJSON,
|
||||
WebAuthnCredential,
|
||||
} from '@simplewebauthn/server';
|
||||
import type {
|
||||
AccountPasskeyChallengeScope,
|
||||
AccountPasskeyCredential,
|
||||
AccountPasskeyPrfStatus,
|
||||
Env,
|
||||
WebAuthnPrfDecryptionOption,
|
||||
} from '../types';
|
||||
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
|
||||
|
||||
const ACCOUNT_PASSKEY_TOKEN_TYPE = 'nodewarden.account-passkey.challenge.v1';
|
||||
const ACCOUNT_PASSKEY_TOKEN_TTL_MS = 17 * 60 * 1000;
|
||||
const ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS = 7 * 60 * 1000;
|
||||
const DEFAULT_RP_NAME = 'NodeWarden';
|
||||
|
||||
interface AccountPasskeyTokenPayload {
|
||||
typ: typeof ACCOUNT_PASSKEY_TOKEN_TYPE;
|
||||
scope: AccountPasskeyChallengeScope;
|
||||
challenge: string;
|
||||
userId: string | null;
|
||||
rpId: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
function textBytes(value: string): Uint8Array {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
async function importHmacKey(secret: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
|
||||
}
|
||||
|
||||
async function hmacSha256(secret: string, data: string): Promise<Uint8Array> {
|
||||
const key = await importHmacKey(secret);
|
||||
return new Uint8Array(await crypto.subtle.sign('HMAC', key, textBytes(data)));
|
||||
}
|
||||
|
||||
function encodeJson(value: unknown): string {
|
||||
return bytesToBase64Url(textBytes(JSON.stringify(value)));
|
||||
}
|
||||
|
||||
function decodeJson<T>(value: string): T | null {
|
||||
try {
|
||||
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(value))) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sha256Base64Url(value: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', textBytes(value));
|
||||
return bytesToBase64Url(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
export function accountPasskeyTokenTtlMs(scope: AccountPasskeyChallengeScope): number {
|
||||
return scope === 'CreateCredential' ? ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS : ACCOUNT_PASSKEY_TOKEN_TTL_MS;
|
||||
}
|
||||
|
||||
export async function createAccountPasskeyToken(
|
||||
env: Env,
|
||||
input: {
|
||||
scope: AccountPasskeyChallengeScope;
|
||||
challenge: string;
|
||||
userId?: string | null;
|
||||
rpId: string;
|
||||
ttlMs?: number;
|
||||
}
|
||||
): Promise<string> {
|
||||
const now = Date.now();
|
||||
const payload: AccountPasskeyTokenPayload = {
|
||||
typ: ACCOUNT_PASSKEY_TOKEN_TYPE,
|
||||
scope: input.scope,
|
||||
challenge: input.challenge,
|
||||
userId: input.userId ?? null,
|
||||
rpId: input.rpId,
|
||||
iat: now,
|
||||
exp: now + (input.ttlMs ?? accountPasskeyTokenTtlMs(input.scope)),
|
||||
};
|
||||
const header = { alg: 'HS256', typ: 'JWT' };
|
||||
const data = `${encodeJson(header)}.${encodeJson(payload)}`;
|
||||
const signature = bytesToBase64Url(await hmacSha256(env.JWT_SECRET, data));
|
||||
return `${data}.${signature}`;
|
||||
}
|
||||
|
||||
export async function verifyAccountPasskeyToken(
|
||||
env: Env,
|
||||
token: string,
|
||||
scope: AccountPasskeyChallengeScope
|
||||
): Promise<AccountPasskeyTokenPayload | null> {
|
||||
try {
|
||||
const parts = String(token || '').split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
const data = `${parts[0]}.${parts[1]}`;
|
||||
const expected = await hmacSha256(env.JWT_SECRET, data);
|
||||
const actual = base64UrlToBytes(parts[2]);
|
||||
if (actual.length !== expected.length) return null;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < actual.length; i += 1) diff |= actual[i] ^ expected[i];
|
||||
if (diff !== 0) return null;
|
||||
|
||||
const payload = decodeJson<AccountPasskeyTokenPayload>(parts[1]);
|
||||
if (!payload || payload.typ !== ACCOUNT_PASSKEY_TOKEN_TYPE || payload.scope !== scope) return null;
|
||||
if (!payload.challenge || !payload.rpId || !Number.isFinite(payload.exp)) return null;
|
||||
if (payload.exp < Date.now()) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getAccountPasskeyRpConfig(request: Request, env: Env): { rpId: string; rpName: string; origins: string[] } {
|
||||
const url = new URL(request.url);
|
||||
const configuredRpId = String(env.WEBAUTHN_RP_ID || '').trim();
|
||||
const rpId = configuredRpId || url.hostname;
|
||||
const rpName = String(env.WEBAUTHN_RP_NAME || '').trim() || DEFAULT_RP_NAME;
|
||||
const configuredOrigins = String(env.WEBAUTHN_ALLOWED_ORIGINS || '')
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
const origins = new Set<string>([url.origin, ...configuredOrigins]);
|
||||
const requestOrigin = request.headers.get('Origin');
|
||||
if (
|
||||
requestOrigin
|
||||
&& (
|
||||
requestOrigin.startsWith('chrome-extension://')
|
||||
|| requestOrigin.startsWith('moz-extension://')
|
||||
|| requestOrigin.startsWith('safari-web-extension://')
|
||||
)
|
||||
) {
|
||||
origins.add(requestOrigin);
|
||||
}
|
||||
return { rpId, rpName, origins: Array.from(origins) };
|
||||
}
|
||||
|
||||
export function userIdToWebAuthnUserId(userId: string): Uint8Array {
|
||||
return textBytes(userId);
|
||||
}
|
||||
|
||||
export function userHandleToUserId(userHandle: string | undefined): string | null {
|
||||
if (!userHandle) return null;
|
||||
try {
|
||||
const decoded = new TextDecoder().decode(base64UrlToBytes(userHandle));
|
||||
return decoded.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function accountPasskeyPrfStatus(credential: Pick<AccountPasskeyCredential, 'supportsPrf' | 'encryptedUserKey' | 'encryptedPublicKey' | 'encryptedPrivateKey'>): AccountPasskeyPrfStatus {
|
||||
if (!credential.supportsPrf) return 2;
|
||||
if (credential.encryptedUserKey && credential.encryptedPublicKey && credential.encryptedPrivateKey) return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function buildWebAuthnPrfOption(
|
||||
credential: AccountPasskeyCredential
|
||||
): WebAuthnPrfDecryptionOption | null {
|
||||
if (accountPasskeyPrfStatus(credential) !== 0) return null;
|
||||
return {
|
||||
EncryptedPrivateKey: credential.encryptedPrivateKey!,
|
||||
EncryptedUserKey: credential.encryptedUserKey!,
|
||||
CredentialId: credential.credentialId,
|
||||
Transports: credential.transports || [],
|
||||
Object: 'webAuthnPrfDecryptionOption',
|
||||
};
|
||||
}
|
||||
|
||||
export function accountPasskeyCredentialToResponse(credential: AccountPasskeyCredential): Record<string, unknown> {
|
||||
const prfStatus = accountPasskeyPrfStatus(credential);
|
||||
return {
|
||||
Id: credential.id,
|
||||
id: credential.id,
|
||||
Name: credential.name,
|
||||
name: credential.name,
|
||||
PrfStatus: prfStatus,
|
||||
prfStatus,
|
||||
EncryptedPublicKey: credential.encryptedPublicKey,
|
||||
encryptedPublicKey: credential.encryptedPublicKey,
|
||||
EncryptedUserKey: credential.encryptedUserKey,
|
||||
encryptedUserKey: credential.encryptedUserKey,
|
||||
CreationDate: credential.createdAt,
|
||||
RevisionDate: credential.updatedAt,
|
||||
Object: 'webauthnCredential',
|
||||
object: 'webauthnCredential',
|
||||
};
|
||||
}
|
||||
|
||||
export function toSimpleWebAuthnCredential(credential: AccountPasskeyCredential): WebAuthnCredential {
|
||||
return {
|
||||
id: credential.credentialId,
|
||||
publicKey: Uint8Array.from(base64UrlToBytes(credential.publicKey)),
|
||||
counter: credential.counter,
|
||||
transports: (credential.transports || undefined) as AuthenticatorTransportFuture[] | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeRegistrationResponse(raw: unknown): RegistrationResponseJSON | null {
|
||||
const input = raw && typeof raw === 'object' ? raw as Record<string, any> : null;
|
||||
const response = input?.response && typeof input.response === 'object' ? input.response as Record<string, any> : null;
|
||||
if (!input || !response) return null;
|
||||
const clientDataJSON = response.clientDataJSON || response.clientDataJson;
|
||||
if (!input.id || !input.rawId || !clientDataJSON || !response.attestationObject) return null;
|
||||
return {
|
||||
id: String(input.id),
|
||||
rawId: String(input.rawId),
|
||||
type: 'public-key',
|
||||
authenticatorAttachment: input.authenticatorAttachment,
|
||||
clientExtensionResults: input.clientExtensionResults || input.extensions || {},
|
||||
response: {
|
||||
attestationObject: String(response.attestationObject),
|
||||
clientDataJSON: String(clientDataJSON),
|
||||
authenticatorData: response.authenticatorData ? String(response.authenticatorData) : undefined,
|
||||
transports: Array.isArray(response.transports) ? response.transports.map(String) as AuthenticatorTransportFuture[] : undefined,
|
||||
publicKey: response.publicKey ? String(response.publicKey) : undefined,
|
||||
publicKeyAlgorithm: typeof response.publicKeyAlgorithm === 'number' ? response.publicKeyAlgorithm : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAuthenticationResponse(raw: unknown): AuthenticationResponseJSON | null {
|
||||
const input = raw && typeof raw === 'object' ? raw as Record<string, any> : null;
|
||||
const response = input?.response && typeof input.response === 'object' ? input.response as Record<string, any> : null;
|
||||
if (!input || !response) return null;
|
||||
const clientDataJSON = response.clientDataJSON || response.clientDataJson;
|
||||
if (!input.id || !input.rawId || !clientDataJSON || !response.authenticatorData || !response.signature) return null;
|
||||
return {
|
||||
id: String(input.id),
|
||||
rawId: String(input.rawId),
|
||||
type: 'public-key',
|
||||
authenticatorAttachment: input.authenticatorAttachment,
|
||||
clientExtensionResults: input.clientExtensionResults || input.extensions || {},
|
||||
response: {
|
||||
authenticatorData: String(response.authenticatorData),
|
||||
clientDataJSON: String(clientDataJSON),
|
||||
signature: String(response.signature),
|
||||
userHandle: response.userHandle ? String(response.userHandle) : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAccountPasskeyName(value: unknown): string {
|
||||
const normalized = String(value || '').trim();
|
||||
return (normalized || 'Account passkey').slice(0, 128);
|
||||
}
|
||||
|
||||
export function normalizeTransports(value: unknown): string[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const transports = value.map((item) => String(item || '').trim()).filter(Boolean);
|
||||
return transports.length ? transports.slice(0, 12) : null;
|
||||
}
|
||||
|
||||
export function isSerializedEncString(value: unknown): value is string {
|
||||
const text = String(value || '').trim();
|
||||
if (!text) return false;
|
||||
const parts = text.split('.');
|
||||
if (parts.length !== 2) return false;
|
||||
const type = Number(parts[0]);
|
||||
const bodyParts = parts[1].split('|');
|
||||
if (type === 2) return bodyParts.length === 3 && bodyParts.every(Boolean);
|
||||
if (type === 3 || type === 4) return bodyParts.length === 1 && !!bodyParts[0];
|
||||
if (type === 5 || type === 6) return bodyParts.length === 2 && bodyParts.every(Boolean);
|
||||
return false;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { User, UserDecryptionOptions } from '../types';
|
||||
import { User, UserDecryptionOptions, WebAuthnPrfDecryptionOption } from '../types';
|
||||
|
||||
function normalizeOptionalPublicKey(value: unknown): string {
|
||||
if (value == null) return '';
|
||||
@@ -40,7 +40,8 @@ export function buildMasterPasswordUnlock(
|
||||
}
|
||||
|
||||
export function buildUserDecryptionOptions(
|
||||
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>
|
||||
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>,
|
||||
webAuthnPrfOption: WebAuthnPrfDecryptionOption | null = null
|
||||
): UserDecryptionOptions {
|
||||
return {
|
||||
HasMasterPassword: true,
|
||||
@@ -48,6 +49,7 @@ export function buildUserDecryptionOptions(
|
||||
MasterPasswordUnlock: buildMasterPasswordUnlock(user),
|
||||
TrustedDeviceOption: null,
|
||||
KeyConnectorOption: null,
|
||||
WebAuthnPrfOption: webAuthnPrfOption,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user