Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/middleware/koa-security-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export default function koaSecurityHeaders<StateT, ContextT, ResponseBodyT>(
...['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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<T extends ExperienceInteractionRouterContext>(
router: Router<unknown, T>,
{ libraries, queries, sentinel }: TenantContext
router: Router<unknown, T>,
{ 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()
},
)
}
129 changes: 129 additions & 0 deletions packages/core/src/utils/password-decryption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
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 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()

/**
* 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<string> {
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<string> {
// 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,
})
}
}
2 changes: 2 additions & 0 deletions packages/experience/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
105 changes: 105 additions & 0 deletions packages/experience/src/hooks/use-encryption.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>(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<string> => {
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,
}
}
Loading
Loading