From ad652b8f27a82d2b62a3d55228dce77fbf15e320 Mon Sep 17 00:00:00 2001 From: Kyrre Gjerstad Date: Wed, 8 Oct 2025 17:56:17 +0200 Subject: [PATCH 1/2] feat: encrypt passwords clientside --- .../src/middleware/koa-security-headers.ts | 1 + .../password-verification.ts | 119 ++-- .../core/src/utils/password-decryption.ts | 127 ++++ packages/experience/package.json | 2 + .../experience/src/hooks/use-encryption.ts | 105 +++ .../src/hooks/use-password-sign-in.ts | 172 ++--- packages/experience/src/utils/crypto.ts | 19 + packages/schemas/src/types/interactions.ts | 629 +++++++++--------- pnpm-lock.yaml | 6 + 9 files changed, 725 insertions(+), 455 deletions(-) create mode 100644 packages/core/src/utils/password-decryption.ts create mode 100644 packages/experience/src/hooks/use-encryption.ts create mode 100644 packages/experience/src/utils/crypto.ts diff --git a/packages/core/src/middleware/koa-security-headers.ts b/packages/core/src/middleware/koa-security-headers.ts index 87e20592b4..d36e0e442c 100644 --- a/packages/core/src/middleware/koa-security-headers.ts +++ b/packages/core/src/middleware/koa-security-headers.ts @@ -41,6 +41,7 @@ export default function koaSecurityHeaders( ...['6001', '6002', '6003'].flatMap((port) => [`ws://localhost:${port}`, `http://localhost:${port}`]), // Benefit local dev. 'http://localhost:3000', // From local dev docs/website etc. + 'http://localhost:3001', // From local www app 'http://localhost:3002', // From local dev console. 'http://localhost:5173', // From local website 'http://localhost:5174', // From local blog diff --git a/packages/core/src/routes/experience/verification-routes/password-verification.ts b/packages/core/src/routes/experience/verification-routes/password-verification.ts index 15819d5824..46c80deaac 100644 --- a/packages/core/src/routes/experience/verification-routes/password-verification.ts +++ b/packages/core/src/routes/experience/verification-routes/password-verification.ts @@ -1,73 +1,72 @@ -import { - passwordVerificationPayloadGuard, - SentinelActivityAction, - VerificationType, -} from '@logto/schemas'; -import { Action } from '@logto/schemas/lib/types/log/interaction.js'; -import type Router from 'koa-router'; -import { z } from 'zod'; +import { passwordVerificationPayloadGuard, SentinelActivityAction, VerificationType } from '@logto/schemas' +import { Action } from '@logto/schemas/lib/types/log/interaction.js' +import type Router from 'koa-router' +import { z } from 'zod' -import koaGuard from '#src/middleware/koa-guard.js'; -import type TenantContext from '#src/tenants/TenantContext.js'; +import koaGuard from '#src/middleware/koa-guard.js' +import type TenantContext from '#src/tenants/TenantContext.js' +import { decryptPassword } from '#src/utils/password-decryption.js' -import { withSentinel } from '../classes/libraries/sentinel-guard.js'; -import { PasswordVerification } from '../classes/verifications/password-verification.js'; -import { experienceRoutes } from '../const.js'; -import koaExperienceVerificationsAuditLog from '../middleware/koa-experience-verifications-audit-log.js'; -import { type ExperienceInteractionRouterContext } from '../types.js'; +import { withSentinel } from '../classes/libraries/sentinel-guard.js' +import { PasswordVerification } from '../classes/verifications/password-verification.js' +import { experienceRoutes } from '../const.js' +import koaExperienceVerificationsAuditLog from '../middleware/koa-experience-verifications-audit-log.js' +import type { ExperienceInteractionRouterContext } from '../types.js' export default function passwordVerificationRoutes( - router: Router, - { libraries, queries, sentinel }: TenantContext + router: Router, + { libraries, queries, sentinel }: TenantContext, ) { - router.post( - `${experienceRoutes.verification}/password`, - koaGuard({ - body: passwordVerificationPayloadGuard, - status: [200, 400, 401, 422], - response: z.object({ - verificationId: z.string(), - }), - }), - koaExperienceVerificationsAuditLog({ - type: VerificationType.Password, - action: Action.Submit, - }), - async (ctx, next) => { - const { experienceInteraction } = ctx; - const { identifier, password } = ctx.guard.body; + router.post( + `${experienceRoutes.verification}/password`, + koaGuard({ + body: passwordVerificationPayloadGuard, + status: [200, 400, 401, 422], + response: z.object({ + verificationId: z.string(), + }), + }), + koaExperienceVerificationsAuditLog({ + type: VerificationType.Password, + action: Action.Submit, + }), + async (ctx, next) => { + const { experienceInteraction } = ctx + const { identifier, password: encryptedPassword, seed } = ctx.guard.body - ctx.verificationAuditLog.append({ - payload: { - identifier, - password, - }, - }); + const password = await decryptPassword(encryptedPassword, seed) - const passwordVerification = PasswordVerification.create(libraries, queries, identifier); + ctx.verificationAuditLog.append({ + payload: { + identifier, + password, + }, + }) - await withSentinel( - { - ctx, - sentinel, - action: SentinelActivityAction.Password, - identifier, - payload: { - event: experienceInteraction.interactionEvent, - verificationId: passwordVerification.id, - }, - }, - passwordVerification.verify(password) - ); + const passwordVerification = PasswordVerification.create(libraries, queries, identifier) - experienceInteraction.setVerificationRecord(passwordVerification); - await experienceInteraction.save(); + await withSentinel( + { + ctx, + sentinel, + action: SentinelActivityAction.Password, + identifier, + payload: { + event: experienceInteraction.interactionEvent, + verificationId: passwordVerification.id, + }, + }, + passwordVerification.verify(password), + ) - ctx.body = { verificationId: passwordVerification.id }; + experienceInteraction.setVerificationRecord(passwordVerification) + await experienceInteraction.save() - ctx.status = 200; + ctx.body = { verificationId: passwordVerification.id } - return next(); - } - ); + ctx.status = 200 + + return next() + }, + ) } diff --git a/packages/core/src/utils/password-decryption.ts b/packages/core/src/utils/password-decryption.ts new file mode 100644 index 0000000000..741c1d0d2f --- /dev/null +++ b/packages/core/src/utils/password-decryption.ts @@ -0,0 +1,127 @@ +import crypto from 'node:crypto' +import fs from 'node:fs/promises' +import path from 'node:path' + +import RequestError from '#src/errors/RequestError/index.js' + +/** + * Path to the shared RSA private key file. + * This key is generated and managed by the main app. + * From packages/core, go up 4 levels to project root, then into tmp/login.key + */ +const getPrivateKeyPath = () => { + const cwd = process.cwd() + // CWD is logto/logto/packages/core, need to go up 4 levels to project root + const projectRoot = path.join(cwd, '..', '..', '..', '..') + return path.join(projectRoot, 'tmp', 'login.key') +} + +const PRIVATE_KEY_PATH = getPrivateKeyPath() + +/** + * Cache for the private key to avoid repeated file reads. + * Will be loaded once on first decryption attempt. + */ +let cachedPrivateKey: string | null = null + +/** + * Loads the RSA private key from the shared key file. + * @returns The private key in PEM format + * @throws RequestError if the key cannot be loaded + */ +async function getPrivateKey(): Promise { + if (cachedPrivateKey) { + return cachedPrivateKey + } + + try { + cachedPrivateKey = await fs.readFile(PRIVATE_KEY_PATH, 'utf8') + + if (!cachedPrivateKey || cachedPrivateKey.trim().length === 0) { + throw new Error('Private key file is empty') + } + + return cachedPrivateKey + } catch (error) { + console.error('Failed to load password encryption private key:', error) + throw new RequestError({ + code: 'session.invalid_credentials', + status: 422, + }) + } +} + +/** + * Decrypts password encrypted with RSA-OAEP from the client. + * + * This integrates with the main app's encryption system which uses: + * - RSA-OAEP with SHA-256 + * - Password + seed encryption on client + * - Seed validation to ensure successful decryption + * + * Note: Replay attack prevention is handled by Logto's Sentinel system, + * not by seed validation. The seed is only used to validate decryption success. + * + * @param encryptedPassword - Base64 encoded encrypted password + * @param seed - Expected seed for decryption validation + * @returns Decrypted password + * @throws RequestError if decryption fails or seed is invalid + */ +export async function decryptPassword(encryptedPassword: string, seed: string): Promise { + // Load private key from file + const privateKeyPem = await getPrivateKey() + + try { + // Decrypt the password + const encryptedBuffer = Buffer.from(encryptedPassword, 'base64') + const decrypted = crypto.privateDecrypt( + { + key: privateKeyPem, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: 'sha256', + }, + encryptedBuffer, + ) + + const decryptedText = decrypted.toString('utf8') + + // Validate seed to ensure successful decryption + // Uses constant-time comparison to prevent timing attacks + const extractedSeed = decryptedText.slice(-seed.length) + + try { + const extractedBuffer = Buffer.from(extractedSeed, 'utf8') + const expectedBuffer = Buffer.from(seed, 'utf8') + + if ( + extractedBuffer.length !== expectedBuffer.length || + !crypto.timingSafeEqual(extractedBuffer, expectedBuffer) + ) { + throw new RequestError({ + code: 'session.invalid_credentials', + status: 422, + }) + } + } catch (error) { + if (error instanceof RequestError) { + throw error + } + throw new RequestError({ + code: 'session.invalid_credentials', + status: 422, + }) + } + + // Return password with seed removed + return decryptedText.slice(0, -seed.length) + } catch (error) { + if (error instanceof RequestError) { + throw error + } + console.error('Password decryption error:', error) + throw new RequestError({ + code: 'session.invalid_credentials', + status: 422, + }) + } +} diff --git a/packages/experience/package.json b/packages/experience/package.json index ad1d14316c..3f20a25c70 100644 --- a/packages/experience/package.json +++ b/packages/experience/package.json @@ -43,6 +43,7 @@ "@testing-library/react-hooks": "^8.0.1", "@types/color": "^4.0.0", "@types/jest": "^29.4.0", + "@types/node-forge": "^1.3.11", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@types/react-helmet": "^6.1.6", @@ -68,6 +69,7 @@ "ky": "^1.2.3", "libphonenumber-js": "^1.12.6", "lint-staged": "^15.0.0", + "node-forge": "^1.3.1", "overlayscrollbars": "^2.0.2", "overlayscrollbars-react": "^0.5.0", "postcss": "^8.4.31", diff --git a/packages/experience/src/hooks/use-encryption.ts b/packages/experience/src/hooks/use-encryption.ts new file mode 100644 index 0000000000..855d9ab179 --- /dev/null +++ b/packages/experience/src/hooks/use-encryption.ts @@ -0,0 +1,105 @@ +import { useCallback, useState, useEffect } from 'react' +import ky from 'ky' +import { z } from 'zod' + +import { encryptPassword as encryptPasswordUtil } from '@/utils/crypto' + +/** + * Hook for encrypting sensitive data client-side before sending to the server. + * Uses RSA-OAEP with the server's public key for encryption. + * + * This connects to the main app's encryption endpoints to get the public key and seeds. + */ + +// TRPC response schema for queries and mutations +const trpcResponseSchema = z.object({ + result: z.object({ + data: z.object({ + json: z.string(), + }), + }), +}) + +export function useEncryption() { + const [publicKey, setPublicKey] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [isError, setIsError] = useState(false) + + // Fetch public key from main app + useEffect(() => { + const fetchPublicKey = async () => { + try { + // Fetch from main app's TRPC endpoint using ky + const data = await ky.get('http://localhost:3001/api/trpc/auth.publicKey').json() + + const parsed = trpcResponseSchema.safeParse(data) + if (!parsed.success) { + console.error('Invalid public key response:', parsed.error) + throw new Error('Invalid public key response format') + } + + setPublicKey(parsed.data.result.data.json) + setIsLoading(false) + } catch (error) { + console.error('Failed to fetch public key:', error) + setIsError(true) + setIsLoading(false) + } + } + + void fetchPublicKey() + }, []) + + /** + * Get a seed from the main app server + */ + const getSeed = useCallback(async (): Promise => { + try { + const data = await ky + .post('http://localhost:3001/api/trpc/auth.getSeed', { + headers: { + 'Content-Type': 'application/json', + }, + }) + .json() + + const parsed = trpcResponseSchema.safeParse(data) + if (!parsed.success) { + console.error('Invalid seed response:', parsed.error) + throw new Error('Invalid seed response format') + } + + return parsed.data.result.data.json + } catch (error) { + console.error('Failed to get seed:', error) + throw new Error('Encryption seed not available') + } + }, []) + + /** + * Encrypt data with a server-provided seed for transmission to the server. + * + * The seed must be obtained from the server immediately before calling this function. + * @throws Error if public key is not available + */ + const encrypt = useCallback( + async (data: string): Promise<{ encrypted: string; seed: string }> => { + if (!publicKey) { + throw new Error('Encryption key not available. Please refresh the page and try again.') + } + + const seed = await getSeed() + const encrypted = encryptPasswordUtil(data, seed, publicKey) + + return { encrypted, seed } + }, + [publicKey, getSeed], + ) + + return { + encrypt, + isLoading, + isError, + isReady: !!publicKey, + } +} diff --git a/packages/experience/src/hooks/use-password-sign-in.ts b/packages/experience/src/hooks/use-password-sign-in.ts index ad859e2f9c..1da6017612 100644 --- a/packages/experience/src/hooks/use-password-sign-in.ts +++ b/packages/experience/src/hooks/use-password-sign-in.ts @@ -1,78 +1,98 @@ -import { - InteractionEvent, - SignInIdentifier, - type PasswordVerificationPayload, -} from '@logto/schemas'; -import { useCallback, useMemo, useState, useContext } from 'react'; - -import CaptchaContext from '@/Providers/CaptchaContextProvider/CaptchaContext'; -import { signInWithPasswordIdentifier } from '@/apis/experience'; -import useApi from '@/hooks/use-api'; -import useCheckSingleSignOn from '@/hooks/use-check-single-sign-on'; -import type { ErrorHandlers } from '@/hooks/use-error-handler'; -import useErrorHandler from '@/hooks/use-error-handler'; - -import useGlobalRedirectTo from './use-global-redirect-to'; -import useSubmitInteractionErrorHandler from './use-submit-interaction-error-handler'; +import { InteractionEvent, SignInIdentifier, type PasswordVerificationPayload } from '@logto/schemas' +import { useCallback, useMemo, useState, useContext } from 'react' + +import CaptchaContext from '@/Providers/CaptchaContextProvider/CaptchaContext' +import { signInWithPasswordIdentifier } from '@/apis/experience' +import useApi from '@/hooks/use-api' +import useCheckSingleSignOn from '@/hooks/use-check-single-sign-on' +import type { ErrorHandlers } from '@/hooks/use-error-handler' +import useErrorHandler from '@/hooks/use-error-handler' + +import { useEncryption } from './use-encryption' +import useGlobalRedirectTo from './use-global-redirect-to' +import useSubmitInteractionErrorHandler from './use-submit-interaction-error-handler' const usePasswordSignIn = () => { - const [errorMessage, setErrorMessage] = useState(); - const { onSubmit: checkSingleSignOn } = useCheckSingleSignOn(); - const redirectTo = useGlobalRedirectTo(); - const { executeCaptcha } = useContext(CaptchaContext); - - const clearErrorMessage = useCallback(() => { - setErrorMessage(''); - }, []); - - const handleError = useErrorHandler(); - const asyncSignIn = useApi(signInWithPasswordIdentifier); - const preSignInErrorHandler = useSubmitInteractionErrorHandler(InteractionEvent.SignIn); - - const errorHandlers: ErrorHandlers = useMemo( - () => ({ - 'session.invalid_credentials': (error) => { - setErrorMessage(error.message); - }, - ...preSignInErrorHandler, - }), - [preSignInErrorHandler] - ); - - const onSubmit = useCallback( - async (payload: PasswordVerificationPayload) => { - const { identifier } = payload; - const captchaToken = await executeCaptcha(); - - // Check if the email is registered with any SSO connectors. If the email is registered with any SSO connectors, we should not proceed to the next step - if (identifier.type === SignInIdentifier.Email) { - const result = await checkSingleSignOn(identifier.value); - - if (result) { - return; - } - } - - const [error, result] = await asyncSignIn(payload, captchaToken); - - if (error) { - await handleError(error, errorHandlers); - - return; - } - - if (result?.redirectTo) { - await redirectTo(result.redirectTo); - } - }, - [asyncSignIn, checkSingleSignOn, errorHandlers, handleError, redirectTo, executeCaptcha] - ); - - return { - errorMessage, - clearErrorMessage, - onSubmit, - }; -}; - -export default usePasswordSignIn; + const [errorMessage, setErrorMessage] = useState() + const { onSubmit: checkSingleSignOn } = useCheckSingleSignOn() + const redirectTo = useGlobalRedirectTo() + const { executeCaptcha } = useContext(CaptchaContext) + const { encrypt, isReady: isEncryptionReady } = useEncryption() + + const clearErrorMessage = useCallback(() => { + setErrorMessage('') + }, []) + + const handleError = useErrorHandler() + const asyncSignIn = useApi(signInWithPasswordIdentifier) + const preSignInErrorHandler = useSubmitInteractionErrorHandler(InteractionEvent.SignIn) + + const errorHandlers: ErrorHandlers = useMemo( + () => ({ + 'session.invalid_credentials': (error) => { + setErrorMessage(error.message) + }, + ...preSignInErrorHandler, + }), + [preSignInErrorHandler], + ) + + const onSubmit = useCallback( + async (payload: PasswordVerificationPayload) => { + const { identifier, password } = payload + const captchaToken = await executeCaptcha() + + // Check if the email is registered with any SSO connectors. If the email is registered with any SSO connectors, we should not proceed to the next step + if (identifier.type === SignInIdentifier.Email) { + const result = await checkSingleSignOn(identifier.value) + + if (result) { + return + } + } + + if (!isEncryptionReady) { + setErrorMessage('Encryption is not ready. Please try again.') + return + } + + const { encrypted, seed } = await encrypt(password) + + const encryptedPayload = { + ...payload, + password: encrypted, + seed, + } + + const [error, result] = await asyncSignIn(encryptedPayload, captchaToken) + + if (error) { + await handleError(error, errorHandlers) + + return + } + + if (result?.redirectTo) { + await redirectTo(result.redirectTo) + } + }, + [ + asyncSignIn, + checkSingleSignOn, + errorHandlers, + handleError, + redirectTo, + executeCaptcha, + encrypt, + isEncryptionReady, + ], + ) + + return { + errorMessage, + clearErrorMessage, + onSubmit, + } +} + +export default usePasswordSignIn diff --git a/packages/experience/src/utils/crypto.ts b/packages/experience/src/utils/crypto.ts new file mode 100644 index 0000000000..7434d52c26 --- /dev/null +++ b/packages/experience/src/utils/crypto.ts @@ -0,0 +1,19 @@ +import forge from 'node-forge'; + +/** + * Encrypts password for web clients using node-forge (browser-compatible) + * This uses node-forge instead of native crypto for browser compatibility + * @param password - Password to encrypt + * @param seed - Seed to append for validation + * @param publicKey - PEM-formatted public key + * @returns Base64 encoded encrypted data + */ +export function encryptPassword(password: string, seed: string, publicKey: string): string { + const key = forge.pki.publicKeyFromPem(publicKey); + const encrypted = key.encrypt(password + seed, 'RSA-OAEP', { + md: forge.md.sha256.create(), + }); + + return forge.util.encode64(encrypted); +} + diff --git a/packages/schemas/src/types/interactions.ts b/packages/schemas/src/types/interactions.ts index c6f05bf43b..9c9e4ac5e0 100644 --- a/packages/schemas/src/types/interactions.ts +++ b/packages/schemas/src/types/interactions.ts @@ -1,214 +1,211 @@ /* eslint-disable max-lines */ -import { emailRegEx, numberAndAlphabetRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit'; -import { z } from 'zod'; +import { emailRegEx, numberAndAlphabetRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit' +import { z } from 'zod' import { - AdditionalIdentifier, - MfaFactor, - SignInIdentifier, - jsonObjectGuard, - webAuthnTransportGuard, -} from '../foundations/index.js'; -import { type ToZodObject } from '../utils/zod.js'; - -import type { - EmailVerificationCodePayload, - PhoneVerificationCodePayload, -} from './verification-code.js'; -import { - emailVerificationCodePayloadGuard, - phoneVerificationCodePayloadGuard, -} from './verification-code.js'; + AdditionalIdentifier, + MfaFactor, + SignInIdentifier, + jsonObjectGuard, + webAuthnTransportGuard, +} from '../foundations/index.js' +import type { ToZodObject } from '../utils/zod.js' + +import type { EmailVerificationCodePayload, PhoneVerificationCodePayload } from './verification-code.js' +import { emailVerificationCodePayloadGuard, phoneVerificationCodePayloadGuard } from './verification-code.js' /** * User interaction events defined in Logto RFC 0004. * @see {@link https://github.com/logto-io/rfcs | Logto RFCs} for more information. */ export enum InteractionEvent { - SignIn = 'SignIn', - Register = 'Register', - ForgotPassword = 'ForgotPassword', + SignIn = 'SignIn', + Register = 'Register', + ForgotPassword = 'ForgotPassword', } export type VerificationIdentifier = { - type: SignInIdentifier | AdditionalIdentifier; - value: string; -}; + type: SignInIdentifier | AdditionalIdentifier + value: string +} export const verificationIdentifierGuard = z.object({ - type: z.union([z.nativeEnum(SignInIdentifier), z.nativeEnum(AdditionalIdentifier)]), - value: z.string(), -}) satisfies ToZodObject; + type: z.union([z.nativeEnum(SignInIdentifier), z.nativeEnum(AdditionalIdentifier)]), + value: z.string(), +}) satisfies ToZodObject // ====== Experience API payload guards and type definitions start ====== /** Identifiers that can be used to uniquely identify a user. */ export type InteractionIdentifier = { - type: T; - value: string; -}; + type: T + value: string +} export const interactionIdentifierGuard = z.object({ - type: z.nativeEnum(SignInIdentifier), - value: z.string(), -}) satisfies ToZodObject; + type: z.nativeEnum(SignInIdentifier), + value: z.string(), +}) satisfies ToZodObject -export type VerificationCodeSignInIdentifier = SignInIdentifier.Email | SignInIdentifier.Phone; +export type VerificationCodeSignInIdentifier = SignInIdentifier.Email | SignInIdentifier.Phone /** Currently only email and phone are supported for verification code validation. */ -export type VerificationCodeIdentifier< - T extends VerificationCodeSignInIdentifier = VerificationCodeSignInIdentifier, -> = { - type: T; - value: string; -}; +export type VerificationCodeIdentifier = + { + type: T + value: string + } export const verificationCodeIdentifierGuard = z.object({ - type: z.enum([SignInIdentifier.Email, SignInIdentifier.Phone]), - value: z.string(), -}) satisfies ToZodObject; + type: z.enum([SignInIdentifier.Email, SignInIdentifier.Phone]), + value: z.string(), +}) satisfies ToZodObject // REMARK: API payload guard /** Payload type for `POST /api/experience/verification/{social|sso}/:connectorId/authorization-uri`. */ export type SocialAuthorizationUrlPayload = { - state: string; - redirectUri: string; - scope?: string; -}; + state: string + redirectUri: string + scope?: string +} export const socialAuthorizationUrlPayloadGuard = z.object({ - state: z.string(), - redirectUri: z.string(), - scope: z.string().optional(), -}) satisfies ToZodObject; + state: z.string(), + redirectUri: z.string(), + scope: z.string().optional(), +}) satisfies ToZodObject /** Payload type for `POST /api/experience/verification/{social|sso}/:connectorId/verify`. */ export type SocialVerificationCallbackPayload = { - /** The callback data from the social connector. */ - connectorData: Record; - /** - * Verification ID is used to retrieve the verification record. - * Throws an error if the verification record is not found. - * - * Optional for Google one tap callback as it does not have a pre-created verification record. - **/ - verificationId?: string; -}; + /** The callback data from the social connector. */ + connectorData: Record + /** + * Verification ID is used to retrieve the verification record. + * Throws an error if the verification record is not found. + * + * Optional for Google one tap callback as it does not have a pre-created verification record. + **/ + verificationId?: string +} export const socialVerificationCallbackPayloadGuard = z.object({ - connectorData: jsonObjectGuard, - verificationId: z.string().optional(), -}) satisfies ToZodObject; + connectorData: jsonObjectGuard, + verificationId: z.string().optional(), +}) satisfies ToZodObject /** Payload type for `POST /api/experience/verification/password`. */ export type PasswordVerificationPayload = { - identifier: InteractionIdentifier; - password: string; -}; + identifier: InteractionIdentifier + /** Encrypted password from client */ + password: string + /** Seed for password decryption validation */ + seed: string +} export const passwordVerificationPayloadGuard = z.object({ - identifier: interactionIdentifierGuard, - password: z.string().min(1), -}) satisfies ToZodObject; + identifier: interactionIdentifierGuard, + password: z.string().min(1), + seed: z.string().min(1), +}) satisfies ToZodObject /** Payload type for `POST /api/experience/verification/totp/verify`. */ export type TotpVerificationVerifyPayload = { - code: string; - /** - * Required for verifying the newly created TOTP secret verification record in the session. - * (For new TOTP setup use only) - * - * If not provided, a new TOTP verification will be generated and validated against the user's existing TOTP secret in their profile. - * (For existing TOTP verification use only) - */ - verificationId?: string; -}; + code: string + /** + * Required for verifying the newly created TOTP secret verification record in the session. + * (For new TOTP setup use only) + * + * If not provided, a new TOTP verification will be generated and validated against the user's existing TOTP secret in their profile. + * (For existing TOTP verification use only) + */ + verificationId?: string +} export const totpVerificationVerifyPayloadGuard = z.object({ - code: z.string().min(1), - verificationId: z.string().optional(), -}) satisfies ToZodObject; + code: z.string().min(1), + verificationId: z.string().optional(), +}) satisfies ToZodObject /** Payload type for `POST /api/experience/verification/backup-code/verify */ export type BackupCodeVerificationVerifyPayload = { - code: string; -}; + code: string +} export const backupCodeVerificationVerifyPayloadGuard = z.object({ - code: z.string().min(1), -}) satisfies ToZodObject; + code: z.string().min(1), +}) satisfies ToZodObject /** Payload type for `POST /api/experience/verification/one-time-token/verify` */ export type OneTimeTokenVerificationVerifyPayload = { - /** - * The email address that the one-time token was sent to. Currently only email identifier is supported. - */ - identifier: InteractionIdentifier; - token: string; -}; + /** + * The email address that the one-time token was sent to. Currently only email identifier is supported. + */ + identifier: InteractionIdentifier + token: string +} export const oneTimeTokenVerificationVerifyPayloadGuard = z.object({ - identifier: z.object({ - type: z.literal(SignInIdentifier.Email), - value: z.string().regex(emailRegEx), - }), - token: z.string().min(1), -}) satisfies ToZodObject; + identifier: z.object({ + type: z.literal(SignInIdentifier.Email), + value: z.string().regex(emailRegEx), + }), + token: z.string().min(1), +}) satisfies ToZodObject /** Payload type for `POST /api/experience/identification`. */ export type IdentificationApiPayload = { - /** - * SignIn and ForgotPassword interaction events: - * Required to retrieve the verification record to validate the user's identity. - * - * Register interaction event: - * - If provided, new user profiles will be appended to the registration session using the verified information from the verification record. - * - If not provided, the user creation process will be triggered directly using the existing profile information in the current registration session. - */ - verificationId?: string; - /** - * Link social identity to a related user account with the same email or phone. - * Only applicable for social verification records and a related user account is found. - */ - linkSocialIdentity?: boolean; -}; + /** + * SignIn and ForgotPassword interaction events: + * Required to retrieve the verification record to validate the user's identity. + * + * Register interaction event: + * - If provided, new user profiles will be appended to the registration session using the verified information from the verification record. + * - If not provided, the user creation process will be triggered directly using the existing profile information in the current registration session. + */ + verificationId?: string + /** + * Link social identity to a related user account with the same email or phone. + * Only applicable for social verification records and a related user account is found. + */ + linkSocialIdentity?: boolean +} export const identificationApiPayloadGuard = z.object({ - verificationId: z.string().optional(), - linkSocialIdentity: z.boolean().optional(), -}) satisfies ToZodObject; + verificationId: z.string().optional(), + linkSocialIdentity: z.boolean().optional(), +}) satisfies ToZodObject /** Payload type for `POST /api/experience`. */ export type CreateExperienceApiPayload = { - interactionEvent: InteractionEvent; - captchaToken?: string; -}; + interactionEvent: InteractionEvent + captchaToken?: string +} export const CreateExperienceApiPayloadGuard = z.object({ - interactionEvent: z.nativeEnum(InteractionEvent), - captchaToken: z.string().optional(), -}) satisfies ToZodObject; + interactionEvent: z.nativeEnum(InteractionEvent), + captchaToken: z.string().optional(), +}) satisfies ToZodObject /** Payload type for `POST /api/experience/profile */ export const updateProfileApiPayloadGuard = z.discriminatedUnion('type', [ - z.object({ - type: z.literal(SignInIdentifier.Username), - value: z.string().regex(usernameRegEx), - }), - z.object({ - type: z.literal('password'), - value: z.string(), - }), - z.object({ - type: z.literal(SignInIdentifier.Email), - verificationId: z.string(), - }), - z.object({ - type: z.literal(SignInIdentifier.Phone), - verificationId: z.string(), - }), - z.object({ - type: z.literal('social'), - verificationId: z.string(), - }), - z.object({ - type: z.literal('extraProfile'), - values: z.record(z.string().regex(numberAndAlphabetRegEx), z.unknown()), - }), -]); -export type UpdateProfileApiPayload = z.infer; + z.object({ + type: z.literal(SignInIdentifier.Username), + value: z.string().regex(usernameRegEx), + }), + z.object({ + type: z.literal('password'), + value: z.string(), + }), + z.object({ + type: z.literal(SignInIdentifier.Email), + verificationId: z.string(), + }), + z.object({ + type: z.literal(SignInIdentifier.Phone), + verificationId: z.string(), + }), + z.object({ + type: z.literal('social'), + verificationId: z.string(), + }), + z.object({ + type: z.literal('extraProfile'), + values: z.record(z.string().regex(numberAndAlphabetRegEx), z.unknown()), + }), +]) +export type UpdateProfileApiPayload = z.infer // ====== Experience API payload guard and types definitions end ====== @@ -225,254 +222,248 @@ export type UpdateProfileApiPayload = z.infer; +export type UsernamePasswordPayload = z.infer export const emailPasswordPayloadGuard = z.object({ - email: z.string().min(1), - password: z.string().min(1), -}); -export type EmailPasswordPayload = z.infer; + email: z.string().min(1), + password: z.string().min(1), +}) +export type EmailPasswordPayload = z.infer export const phonePasswordPayloadGuard = z.object({ - phone: z.string().min(1), - password: z.string().min(1), -}); -export type PhonePasswordPayload = z.infer; + phone: z.string().min(1), + password: z.string().min(1), +}) +export type PhonePasswordPayload = z.infer export const socialConnectorPayloadGuard = z.object({ - connectorId: z.string(), - connectorData: jsonObjectGuard, -}); -export type SocialConnectorPayload = z.infer; + connectorId: z.string(), + connectorData: jsonObjectGuard, +}) +export type SocialConnectorPayload = z.infer export const socialEmailPayloadGuard = z.object({ - connectorId: z.string(), - email: z.string(), -}); + connectorId: z.string(), + email: z.string(), +}) -export type SocialEmailPayload = z.infer; +export type SocialEmailPayload = z.infer export const socialPhonePayloadGuard = z.object({ - connectorId: z.string(), - phone: z.string(), -}); + connectorId: z.string(), + phone: z.string(), +}) -export type SocialPhonePayload = z.infer; +export type SocialPhonePayload = z.infer -export const eventGuard = z.nativeEnum(InteractionEvent); +export const eventGuard = z.nativeEnum(InteractionEvent) export const identifierPayloadGuard = z.union([ - usernamePasswordPayloadGuard, - emailPasswordPayloadGuard, - phonePasswordPayloadGuard, - emailVerificationCodePayloadGuard, - phoneVerificationCodePayloadGuard, - socialConnectorPayloadGuard, - socialEmailPayloadGuard, - socialPhonePayloadGuard, -]); + usernamePasswordPayloadGuard, + emailPasswordPayloadGuard, + phonePasswordPayloadGuard, + emailVerificationCodePayloadGuard, + phoneVerificationCodePayloadGuard, + socialConnectorPayloadGuard, + socialEmailPayloadGuard, + socialPhonePayloadGuard, +]) export type IdentifierPayload = - | UsernamePasswordPayload - | EmailPasswordPayload - | PhonePasswordPayload - | EmailVerificationCodePayload - | PhoneVerificationCodePayload - | SocialConnectorPayload - | SocialPhonePayload - | SocialEmailPayload; + | UsernamePasswordPayload + | EmailPasswordPayload + | PhonePasswordPayload + | EmailVerificationCodePayload + | PhoneVerificationCodePayload + | SocialConnectorPayload + | SocialPhonePayload + | SocialEmailPayload export const profileGuard = z.object({ - username: z.string().regex(usernameRegEx).optional(), - email: z.string().regex(emailRegEx).optional(), - phone: z.string().regex(phoneRegEx).optional(), - connectorId: z.string().optional(), - password: z.string().optional(), -}); + username: z.string().regex(usernameRegEx).optional(), + email: z.string().regex(emailRegEx).optional(), + phone: z.string().regex(phoneRegEx).optional(), + connectorId: z.string().optional(), + password: z.string().optional(), +}) -export type Profile = z.infer; +export type Profile = z.infer export enum MissingProfile { - username = 'username', - email = 'email', - phone = 'phone', - password = 'password', - emailOrPhone = 'emailOrPhone', - extraProfile = 'extraProfile', + username = 'username', + email = 'email', + phone = 'phone', + password = 'password', + emailOrPhone = 'emailOrPhone', + extraProfile = 'extraProfile', } export const bindTotpPayloadGuard = z.object({ - // Unlike identifier payload which has indicator like "email", - // mfa payload must have an additional type field to indicate type - type: z.literal(MfaFactor.TOTP), - code: z.string(), -}); + // Unlike identifier payload which has indicator like "email", + // mfa payload must have an additional type field to indicate type + type: z.literal(MfaFactor.TOTP), + code: z.string(), +}) -export type BindTotpPayload = z.infer; +export type BindTotpPayload = z.infer export const bindWebAuthnPayloadGuard = z.object({ - type: z.literal(MfaFactor.WebAuthn), - id: z.string(), - rawId: z.string(), - /** - * The response from WebAuthn API - * - * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential} - */ - response: z.object({ - clientDataJSON: z.string(), - attestationObject: z.string(), - authenticatorData: z.string().optional(), - transports: webAuthnTransportGuard.array().optional(), - publicKeyAlgorithm: z.number().optional(), - publicKey: z.string().optional(), - }), - authenticatorAttachment: z.enum(['cross-platform', 'platform']).optional(), - clientExtensionResults: z.object({ - appid: z.boolean().optional(), - crepProps: z - .object({ - rk: z.boolean().optional(), - }) - .optional(), - hmacCreateSecret: z.boolean().optional(), - }), -}); - -export type BindWebAuthnPayload = z.infer; + type: z.literal(MfaFactor.WebAuthn), + id: z.string(), + rawId: z.string(), + /** + * The response from WebAuthn API + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential} + */ + response: z.object({ + clientDataJSON: z.string(), + attestationObject: z.string(), + authenticatorData: z.string().optional(), + transports: webAuthnTransportGuard.array().optional(), + publicKeyAlgorithm: z.number().optional(), + publicKey: z.string().optional(), + }), + authenticatorAttachment: z.enum(['cross-platform', 'platform']).optional(), + clientExtensionResults: z.object({ + appid: z.boolean().optional(), + crepProps: z + .object({ + rk: z.boolean().optional(), + }) + .optional(), + hmacCreateSecret: z.boolean().optional(), + }), +}) + +export type BindWebAuthnPayload = z.infer export const bindBackupCodePayloadGuard = z.object({ - type: z.literal(MfaFactor.BackupCode), -}); + type: z.literal(MfaFactor.BackupCode), +}) -export type BindBackupCodePayload = z.infer; +export type BindBackupCodePayload = z.infer export const bindMfaPayloadGuard = z.discriminatedUnion('type', [ - bindTotpPayloadGuard, - bindWebAuthnPayloadGuard, - bindBackupCodePayloadGuard, -]); + bindTotpPayloadGuard, + bindWebAuthnPayloadGuard, + bindBackupCodePayloadGuard, +]) -export type BindMfaPayload = z.infer; +export type BindMfaPayload = z.infer /** @deprecated Legacy interaction API use only */ -export const totpVerificationPayloadGuard = bindTotpPayloadGuard; +export const totpVerificationPayloadGuard = bindTotpPayloadGuard /** @deprecated Legacy interaction API use only */ -export type TotpVerificationPayload = z.infer; +export type TotpVerificationPayload = z.infer -export const webAuthnVerificationPayloadGuard = bindWebAuthnPayloadGuard - .omit({ response: true }) - .extend({ - response: z.object({ - clientDataJSON: z.string(), - authenticatorData: z.string(), - signature: z.string(), - userHandle: z.string().optional(), - }), - }); +export const webAuthnVerificationPayloadGuard = bindWebAuthnPayloadGuard.omit({ response: true }).extend({ + response: z.object({ + clientDataJSON: z.string(), + authenticatorData: z.string(), + signature: z.string(), + userHandle: z.string().optional(), + }), +}) -export type WebAuthnVerificationPayload = z.infer; +export type WebAuthnVerificationPayload = z.infer export const backupCodeVerificationPayloadGuard = z.object({ - type: z.literal(MfaFactor.BackupCode), - code: z.string(), -}); + type: z.literal(MfaFactor.BackupCode), + code: z.string(), +}) -export type BackupCodeVerificationPayload = z.infer; +export type BackupCodeVerificationPayload = z.infer export const verifyMfaPayloadGuard = z.discriminatedUnion('type', [ - totpVerificationPayloadGuard, - webAuthnVerificationPayloadGuard, - backupCodeVerificationPayloadGuard, -]); + totpVerificationPayloadGuard, + webAuthnVerificationPayloadGuard, + backupCodeVerificationPayloadGuard, +]) -export type VerifyMfaPayload = z.infer; +export type VerifyMfaPayload = z.infer export const pendingTotpGuard = z.object({ - type: z.literal(MfaFactor.TOTP), - secret: z.string(), -}); + type: z.literal(MfaFactor.TOTP), + secret: z.string(), +}) -export type PendingTotp = z.infer; +export type PendingTotp = z.infer export const pendingWebAuthnGuard = z.object({ - type: z.literal(MfaFactor.WebAuthn), - challenge: z.string(), -}); + type: z.literal(MfaFactor.WebAuthn), + challenge: z.string(), +}) -export type PendingWebAuthn = z.infer; +export type PendingWebAuthn = z.infer export const pendingBackupCodeGuard = z.object({ - type: z.literal(MfaFactor.BackupCode), - codes: z.array(z.string()), -}); + type: z.literal(MfaFactor.BackupCode), + codes: z.array(z.string()), +}) -export type PendingBackupCode = z.infer; +export type PendingBackupCode = z.infer export const pendingEmailVerificationCodeGuard = z.object({ - type: z.literal(MfaFactor.EmailVerificationCode), - email: z.string(), -}); + type: z.literal(MfaFactor.EmailVerificationCode), + email: z.string(), +}) -export type PendingEmailVerificationCode = z.infer; +export type PendingEmailVerificationCode = z.infer export const pendingPhoneVerificationCodeGuard = z.object({ - type: z.literal(MfaFactor.PhoneVerificationCode), - phone: z.string(), -}); + type: z.literal(MfaFactor.PhoneVerificationCode), + phone: z.string(), +}) -export type PendingPhoneVerificationCode = z.infer; +export type PendingPhoneVerificationCode = z.infer // Some information like TOTP secret should be generated in the backend // and stored in the interaction temporarily. export const pendingMfaGuard = z.discriminatedUnion('type', [ - pendingTotpGuard, - pendingWebAuthnGuard, - pendingBackupCodeGuard, - pendingEmailVerificationCodeGuard, - pendingPhoneVerificationCodeGuard, -]); + pendingTotpGuard, + pendingWebAuthnGuard, + pendingBackupCodeGuard, + pendingEmailVerificationCodeGuard, + pendingPhoneVerificationCodeGuard, +]) -export type PendingMfa = z.infer; +export type PendingMfa = z.infer -export const bindTotpGuard = pendingTotpGuard; +export const bindTotpGuard = pendingTotpGuard -export type BindTotp = z.infer; +export type BindTotp = z.infer export const bindWebAuthnGuard = z.object({ - type: z.literal(MfaFactor.WebAuthn), - credentialId: z.string(), - publicKey: z.string(), - transports: webAuthnTransportGuard.array(), - counter: z.number(), - agent: z.string(), - name: z.string().optional(), -}); + type: z.literal(MfaFactor.WebAuthn), + credentialId: z.string(), + publicKey: z.string(), + transports: webAuthnTransportGuard.array(), + counter: z.number(), + agent: z.string(), + name: z.string().optional(), +}) -export type BindWebAuthn = z.infer; +export type BindWebAuthn = z.infer -export const bindBackupCodeGuard = pendingBackupCodeGuard; +export const bindBackupCodeGuard = pendingBackupCodeGuard -export type BindBackupCode = z.infer; +export type BindBackupCode = z.infer // The type for binding new mfa verification to a user, not always equals to the pending type. -export const bindMfaGuard = z.discriminatedUnion('type', [ - bindTotpGuard, - bindWebAuthnGuard, - bindBackupCodeGuard, -]); +export const bindMfaGuard = z.discriminatedUnion('type', [bindTotpGuard, bindWebAuthnGuard, bindBackupCodeGuard]) -export type BindMfa = z.infer; +export type BindMfa = z.infer export const verifyMfaResultGuard = z.object({ - type: z.nativeEnum(MfaFactor), - id: z.string(), -}); + type: z.nativeEnum(MfaFactor), + id: z.string(), +}) -export type VerifyMfaResult = z.infer; +export type VerifyMfaResult = z.infer /* eslint-enable max-lines */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83f4b41b5b..477bd8d440 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4168,6 +4168,9 @@ importers: '@types/jest': specifier: ^29.4.0 version: 29.4.0 + '@types/node-forge': + specifier: ^1.3.11 + version: 1.3.11 '@types/react': specifier: ^18.3.3 version: 18.3.3 @@ -4243,6 +4246,9 @@ importers: lint-staged: specifier: ^15.0.0 version: 15.0.2 + node-forge: + specifier: ^1.3.1 + version: 1.3.1 overlayscrollbars: specifier: ^2.0.2 version: 2.0.3 From d72aba905dd062c7db88dd3c38951cbad925a00b Mon Sep 17 00:00:00 2001 From: Kyrre Gjerstad Date: Thu, 9 Oct 2025 10:48:41 +0200 Subject: [PATCH 2/2] update storage paths --- packages/core/src/utils/password-decryption.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/core/src/utils/password-decryption.ts b/packages/core/src/utils/password-decryption.ts index 741c1d0d2f..fe8f60ff3c 100644 --- a/packages/core/src/utils/password-decryption.ts +++ b/packages/core/src/utils/password-decryption.ts @@ -10,10 +10,12 @@ import RequestError from '#src/errors/RequestError/index.js' * From packages/core, go up 4 levels to project root, then into tmp/login.key */ const getPrivateKeyPath = () => { - const cwd = process.cwd() - // CWD is logto/logto/packages/core, need to go up 4 levels to project root - const projectRoot = path.join(cwd, '..', '..', '..', '..') - return path.join(projectRoot, 'tmp', 'login.key') + const STORAGE_PATH = process.env.STORAGE_PATH + + if (!STORAGE_PATH) { + throw new Error('STORAGE_PATH is not set') + } + return path.join(STORAGE_PATH, 'login.key') } const PRIVATE_KEY_PATH = getPrivateKeyPath()