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,
|
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,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user