mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-17 21:25:52 +02:00
Mitigate account takeover in user registration process
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
co-authored by
Copilot
parent
2fe15ac599
commit
78dfd9317a
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -106,6 +106,13 @@
|
||||
"when": 1775654963847,
|
||||
"tag": "0014_fluffy_ezekiel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1778225646617,
|
||||
"tag": "0015_strange_warbird",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user