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,
passkeyId: string,
prfOutput: ArrayBuffer,
email: string,
keyPair: { publicKey: Uint8Array; privateKey: Uint8Array },
): Promise<void> {
// Derive passkey-based shard from PRF output
@@ -1013,6 +1014,7 @@ export class UnifiedAppointmentCrypto {
passkeyId,
publicKey: this.uint8ArrayToBase64(keyPair.publicKey),
privateKeyShare: this.uint8ArrayToBase64(dbShard),
email,
}),
});
+23 -18
View File
@@ -9,6 +9,7 @@ import {
text,
time,
timestamp,
uniqueIndex,
uuid,
varchar,
} 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
* @table staffCrypto
*/
export const staffCrypto = pgTable("staff_crypto", {
/** Primary key - unique identifier */
id: uuid("id").primaryKey().defaultRandom(),
/** Foreign key to central user table */
userId: uuid("user_id").notNull(),
/** ML-KEM-768 (Kyber) public key for this staff member (Base64 encoded) */
publicKey: text("public_key").notNull(),
/** Database-stored shard of the private key (Base64 encoded) */
privateKeyShare: text("private_key_share").notNull(),
/** Associated passkey ID for key derivation */
passkeyId: text("passkey_id").notNull(),
/** Timestamp when the key was created */
createdAt: timestamp("created_at").defaultNow().notNull(),
/** Timestamp when the key was last updated */
updatedAt: timestamp("updated_at").defaultNow().notNull(),
/** Whether this key is currently active */
isActive: boolean("is_active").default(true).notNull(),
});
export const staffCrypto = pgTable(
"staff_crypto",
{
/** Primary key - unique identifier */
id: uuid("id").primaryKey().defaultRandom(),
/** Foreign key to central user table */
userId: uuid("user_id").notNull(),
/** ML-KEM-768 (Kyber) public key for this staff member (Base64 encoded) */
publicKey: text("public_key").notNull(),
/** Database-stored shard of the private key (Base64 encoded) */
privateKeyShare: text("private_key_share").notNull(),
/** Associated passkey ID for key derivation */
passkeyId: text("passkey_id").notNull(),
/** Timestamp when the key was created */
createdAt: timestamp("created_at").defaultNow().notNull(),
/** Timestamp when the key was last updated */
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
+14
View File
@@ -1,5 +1,11 @@
import logger from "$lib/logger";
type WebAuthnAllowCredential = {
id: string;
type: "public-key";
transports?: AuthenticatorTransport[];
};
export const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
try {
const bytes = new Uint8Array(buffer);
@@ -81,6 +87,7 @@ export const fetchChallenge = async (email: string) => {
return {
id: data.rpId,
challenge: data.challenge,
allowCredentials: data.allowCredentials,
};
};
@@ -252,11 +259,13 @@ export const getCredential = async ({
challenge,
email,
enablePRF = false,
allowCredentials,
}: {
id: string;
challenge: string;
email: string;
enablePRF?: boolean;
allowCredentials?: WebAuthnAllowCredential[];
}): Promise<GetCredentialResponse> => {
// Build WebAuthn options manually to support PRF
const challengeBuffer = base64UrlToArrayBuffer(challenge);
@@ -265,6 +274,11 @@ export const getCredential = async ({
challenge: challengeBuffer,
rpId: id,
userVerification: "preferred",
allowCredentials: allowCredentials?.map((credential) => ({
id: base64UrlToArrayBuffer(credential.id),
type: credential.type,
transports: credential.transports,
})),
};
// Add PRF extension if enabled
@@ -196,7 +196,14 @@
if (tenantId && passkeyId && prfOutput && kyberKeyPair) {
const crypto = new UnifiedAppointmentCrypto();
return await crypto
.storeStaffKeyPair(tenantId, $formData.userId, passkeyId, prfOutput, kyberKeyPair)
.storeStaffKeyPair(
tenantId,
$formData.userId,
passkeyId,
prfOutput,
$formData.email,
kyberKeyPair,
)
.then(() => {
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 { logger } from "$lib/logger";
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 { 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
registerOpenAPIRoute("/tenants/{id}/staff/{staffId}/crypto", "POST", {
@@ -111,10 +145,26 @@ registerOpenAPIRoute("/tenants/{id}/staff/{staffId}/crypto", "POST", {
});
const requestSchema = z.object({
passkeyId: z.string().min(1, "Passkey ID is required"),
publicKey: z.string().min(1, "Public key is required"),
privateKeyShare: z.string().min(1, "Private key share is required"),
email: z.string().email("Valid email is required").optional(), // Required for cookie validation
passkeyId: z
.string()
.min(16, "Passkey ID is required")
.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 }) => {
@@ -140,8 +190,9 @@ export const POST: RequestHandler = async ({ params, locals, request, cookies })
// Authentication: Accept either active session OR registration cookie
const isAuthenticated = locals.user && locals.user.id === staffId;
const registrationEmail = cookies.get("webauthn-registration-email");
const isRegistration = registrationEmail === email;
const registrationEmail = normalizeEmail(cookies.get("webauthn-registration-email"));
const requestEmail = normalizeEmail(email);
const isRegistration = !!registrationEmail && !!requestEmail && registrationEmail === requestEmail;
if (!isAuthenticated && !isRegistration) {
log.warn("Unauthorized crypto key storage attempt", {
@@ -149,13 +200,29 @@ export const POST: RequestHandler = async ({ params, locals, request, cookies })
staffId,
requesterId: locals.user?.id,
hasRegistrationCookie: !!registrationEmail,
emailsMatch: registrationEmail === email,
emailsMatch: registrationEmail === requestEmail,
});
throw new AuthorizationError();
}
// For authenticated users, verify tenant access
if (isAuthenticated) {
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 });
@@ -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,
"tag": "0014_fluffy_ezekiel",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1778225646617,
"tag": "0015_strange_warbird",
"breakpoints": true
}
]
}
}