Mitigate account takeover in user registration process

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Hendrik Belitz
2026-05-08 10:47:08 +02:00
co-authored by Copilot
parent 2fe15ac599
commit 78dfd9317a
8 changed files with 1184 additions and 28 deletions
+2
View File
@@ -994,6 +994,7 @@ export class UnifiedAppointmentCrypto {
staffId: string, staffId: string,
passkeyId: string, passkeyId: string,
prfOutput: ArrayBuffer, prfOutput: ArrayBuffer,
email: string,
keyPair: { publicKey: Uint8Array; privateKey: Uint8Array }, keyPair: { publicKey: Uint8Array; privateKey: Uint8Array },
): Promise<void> { ): Promise<void> {
// Derive passkey-based shard from PRF output // Derive passkey-based shard from PRF output
@@ -1013,6 +1014,7 @@ export class UnifiedAppointmentCrypto {
passkeyId, passkeyId,
publicKey: this.uint8ArrayToBase64(keyPair.publicKey), publicKey: this.uint8ArrayToBase64(keyPair.publicKey),
privateKeyShare: this.uint8ArrayToBase64(dbShard), privateKeyShare: this.uint8ArrayToBase64(dbShard),
email,
}), }),
}); });
+23 -18
View File
@@ -9,6 +9,7 @@ import {
text, text,
time, time,
timestamp, timestamp,
uniqueIndex,
uuid, uuid,
varchar, varchar,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
@@ -289,24 +290,28 @@ export type SelectNotification = InferSelectModel<typeof notification>;
* Enables end-to-end encryption for appointments in tenant database * Enables end-to-end encryption for appointments in tenant database
* @table staffCrypto * @table staffCrypto
*/ */
export const staffCrypto = pgTable("staff_crypto", { export const staffCrypto = pgTable(
/** Primary key - unique identifier */ "staff_crypto",
id: uuid("id").primaryKey().defaultRandom(), {
/** Foreign key to central user table */ /** Primary key - unique identifier */
userId: uuid("user_id").notNull(), id: uuid("id").primaryKey().defaultRandom(),
/** ML-KEM-768 (Kyber) public key for this staff member (Base64 encoded) */ /** Foreign key to central user table */
publicKey: text("public_key").notNull(), userId: uuid("user_id").notNull(),
/** Database-stored shard of the private key (Base64 encoded) */ /** ML-KEM-768 (Kyber) public key for this staff member (Base64 encoded) */
privateKeyShare: text("private_key_share").notNull(), publicKey: text("public_key").notNull(),
/** Associated passkey ID for key derivation */ /** Database-stored shard of the private key (Base64 encoded) */
passkeyId: text("passkey_id").notNull(), privateKeyShare: text("private_key_share").notNull(),
/** Timestamp when the key was created */ /** Associated passkey ID for key derivation */
createdAt: timestamp("created_at").defaultNow().notNull(), passkeyId: text("passkey_id").notNull(),
/** Timestamp when the key was last updated */ /** Timestamp when the key was created */
updatedAt: timestamp("updated_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
/** Whether this key is currently active */ /** Timestamp when the key was last updated */
isActive: boolean("is_active").default(true).notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(),
}); /** Whether this key is currently active */
isActive: boolean("is_active").default(true).notNull(),
},
(table) => [uniqueIndex("staff_crypto_ua_idx").on(table.userId, table.isActive)],
);
/** /**
* ClientAppointmentTunnels table - represents encrypted appointment tunnels for clients * ClientAppointmentTunnels table - represents encrypted appointment tunnels for clients
+14
View File
@@ -1,5 +1,11 @@
import logger from "$lib/logger"; import logger from "$lib/logger";
type WebAuthnAllowCredential = {
id: string;
type: "public-key";
transports?: AuthenticatorTransport[];
};
export const arrayBufferToBase64 = (buffer: ArrayBuffer): string => { export const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
try { try {
const bytes = new Uint8Array(buffer); const bytes = new Uint8Array(buffer);
@@ -81,6 +87,7 @@ export const fetchChallenge = async (email: string) => {
return { return {
id: data.rpId, id: data.rpId,
challenge: data.challenge, challenge: data.challenge,
allowCredentials: data.allowCredentials,
}; };
}; };
@@ -252,11 +259,13 @@ export const getCredential = async ({
challenge, challenge,
email, email,
enablePRF = false, enablePRF = false,
allowCredentials,
}: { }: {
id: string; id: string;
challenge: string; challenge: string;
email: string; email: string;
enablePRF?: boolean; enablePRF?: boolean;
allowCredentials?: WebAuthnAllowCredential[];
}): Promise<GetCredentialResponse> => { }): Promise<GetCredentialResponse> => {
// Build WebAuthn options manually to support PRF // Build WebAuthn options manually to support PRF
const challengeBuffer = base64UrlToArrayBuffer(challenge); const challengeBuffer = base64UrlToArrayBuffer(challenge);
@@ -265,6 +274,11 @@ export const getCredential = async ({
challenge: challengeBuffer, challenge: challengeBuffer,
rpId: id, rpId: id,
userVerification: "preferred", userVerification: "preferred",
allowCredentials: allowCredentials?.map((credential) => ({
id: base64UrlToArrayBuffer(credential.id),
type: credential.type,
transports: credential.transports,
})),
}; };
// Add PRF extension if enabled // Add PRF extension if enabled
@@ -196,7 +196,14 @@
if (tenantId && passkeyId && prfOutput && kyberKeyPair) { if (tenantId && passkeyId && prfOutput && kyberKeyPair) {
const crypto = new UnifiedAppointmentCrypto(); const crypto = new UnifiedAppointmentCrypto();
return await crypto return await crypto
.storeStaffKeyPair(tenantId, $formData.userId, passkeyId, prfOutput, kyberKeyPair) .storeStaffKeyPair(
tenantId,
$formData.userId,
passkeyId,
prfOutput,
$formData.email,
kyberKeyPair,
)
.then(() => { .then(() => {
toast.success(m["setupPasskey.successKeyPairSaved"]()); toast.success(m["setupPasskey.successKeyPairSaved"]());
}) })
@@ -12,9 +12,43 @@ import type { RequestHandler } from "@sveltejs/kit";
import { StaffCryptoService } from "$lib/server/services/staff-crypto.service"; import { StaffCryptoService } from "$lib/server/services/staff-crypto.service";
import { logger } from "$lib/logger"; import { logger } from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions"; import { checkPermission } from "$lib/server/utils/permissions";
import { BackendError, ValidationError, InternalError, logError } from "$lib/server/utils/errors"; import {
BackendError,
ValidationError,
InternalError,
logError,
AuthorizationError,
} from "$lib/server/utils/errors";
import { z } from "zod"; import { z } from "zod";
import { registerOpenAPIRoute } from "$lib/server/openapi"; import { registerOpenAPIRoute } from "$lib/server/openapi";
import { centralDb } from "$lib/server/db";
import { user } from "$lib/server/db/central-schema";
import { eq } from "drizzle-orm";
const ML_KEM_768_PUBLIC_KEY_BYTES = 1184;
const ML_KEM_768_PRIVATE_KEY_BYTES = 2400;
const passkeyIdBase64UrlPattern = /^[A-Za-z0-9_-]+$/;
const base64Pattern = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
const isBase64String = (value: string): boolean => {
return value.length > 0 && value.length % 4 === 0 && base64Pattern.test(value);
};
const hasDecodedByteLength = (value: string, expectedBytes: number): boolean => {
try {
return Buffer.from(value, "base64").length === expectedBytes;
} catch {
return false;
}
};
const normalizeEmail = (value?: string | null): string | undefined => {
if (!value) {
return undefined;
}
return value.trim().toLowerCase();
};
// Register OpenAPI documentation // Register OpenAPI documentation
registerOpenAPIRoute("/tenants/{id}/staff/{staffId}/crypto", "POST", { registerOpenAPIRoute("/tenants/{id}/staff/{staffId}/crypto", "POST", {
@@ -111,10 +145,26 @@ registerOpenAPIRoute("/tenants/{id}/staff/{staffId}/crypto", "POST", {
}); });
const requestSchema = z.object({ const requestSchema = z.object({
passkeyId: z.string().min(1, "Passkey ID is required"), passkeyId: z
publicKey: z.string().min(1, "Public key is required"), .string()
privateKeyShare: z.string().min(1, "Private key share is required"), .min(16, "Passkey ID is required")
email: z.string().email("Valid email is required").optional(), // Required for cookie validation .max(1024, "Passkey ID is too long")
.regex(passkeyIdBase64UrlPattern, "Passkey ID must be base64url encoded"),
publicKey: z
.string()
.refine((value) => isBase64String(value), "Public key must be valid Base64")
.refine(
(value) => hasDecodedByteLength(value, ML_KEM_768_PUBLIC_KEY_BYTES),
`Public key must decode to ${ML_KEM_768_PUBLIC_KEY_BYTES} bytes (ML-KEM-768)`,
),
privateKeyShare: z
.string()
.refine((value) => isBase64String(value), "Private key share must be valid Base64")
.refine(
(value) => hasDecodedByteLength(value, ML_KEM_768_PRIVATE_KEY_BYTES),
`Private key share must decode to ${ML_KEM_768_PRIVATE_KEY_BYTES} bytes (ML-KEM-768)`,
),
email: z.email("Valid email is required").optional(), // Required for cookie validation
}); });
export const POST: RequestHandler = async ({ params, locals, request, cookies }) => { export const POST: RequestHandler = async ({ params, locals, request, cookies }) => {
@@ -140,8 +190,9 @@ export const POST: RequestHandler = async ({ params, locals, request, cookies })
// Authentication: Accept either active session OR registration cookie // Authentication: Accept either active session OR registration cookie
const isAuthenticated = locals.user && locals.user.id === staffId; const isAuthenticated = locals.user && locals.user.id === staffId;
const registrationEmail = cookies.get("webauthn-registration-email"); const registrationEmail = normalizeEmail(cookies.get("webauthn-registration-email"));
const isRegistration = registrationEmail === email; const requestEmail = normalizeEmail(email);
const isRegistration = !!registrationEmail && !!requestEmail && registrationEmail === requestEmail;
if (!isAuthenticated && !isRegistration) { if (!isAuthenticated && !isRegistration) {
log.warn("Unauthorized crypto key storage attempt", { log.warn("Unauthorized crypto key storage attempt", {
@@ -149,13 +200,29 @@ export const POST: RequestHandler = async ({ params, locals, request, cookies })
staffId, staffId,
requesterId: locals.user?.id, requesterId: locals.user?.id,
hasRegistrationCookie: !!registrationEmail, hasRegistrationCookie: !!registrationEmail,
emailsMatch: registrationEmail === email, emailsMatch: registrationEmail === requestEmail,
}); });
throw new AuthorizationError();
} }
// For authenticated users, verify tenant access // For authenticated users, verify tenant access
if (isAuthenticated) { if (isAuthenticated) {
checkPermission(locals, tenantId, false); checkPermission(locals, tenantId, false);
} else {
const staffUser = await centralDb
.select({ id: user.id })
.from(user)
.where(eq(user.id, staffId))
.limit(1);
if (staffUser.length === 0) {
log.warn("Rejected registration crypto key storage for unknown staff user", {
tenantId,
staffId,
hasRegistrationCookie: !!registrationEmail,
});
throw new AuthorizationError();
}
} }
log.debug("Storing staff crypto keys", { tenantId, staffId, passkeyId }); log.debug("Storing staff crypto keys", { tenantId, staffId, passkeyId });
@@ -0,0 +1 @@
CREATE UNIQUE INDEX "staff_crypto_ua_idx" ON "staff_crypto" USING btree ("user_id","is_active");
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -106,6 +106,13 @@
"when": 1775654963847, "when": 1775654963847,
"tag": "0014_fluffy_ezekiel", "tag": "0014_fluffy_ezekiel",
"breakpoints": true "breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1778225646617,
"tag": "0015_strange_warbird",
"breakpoints": true
} }
] ]
} }