Implement macro for signature verification

This commit is contained in:
ian
2025-12-03 17:19:55 +07:00
parent 8bab86bdaf
commit b4bdbdedc2
3 changed files with 146 additions and 34 deletions
+11 -6
View File
@@ -102,8 +102,13 @@ export function canonicalizeObject<T = unknown>(data: T): unknown {
* @param data The data to canonicalize. * @param data The data to canonicalize.
* @returns The canonical JSON string. * @returns The canonical JSON string.
*/ */
export function canonicalize(data: unknown): string { export function canonicalize(data: unknown, timestamp: number, nonce: string): string {
return JSON.stringify(canonicalizeObject(data)) const body = canonicalizeObject(data)
return JSON.stringify({
timestamp,
nonce,
body,
})
} }
/** /**
@@ -113,9 +118,9 @@ export function canonicalize(data: unknown): string {
* @param secretKey The secret key. * @param secretKey The secret key.
* @returns The signature. * @returns The signature.
*/ */
export function sign(data: unknown, secretKey: string): string { export function sign(data: unknown, timestamp: number, nonce: string, secretKey: string): string {
return createHmac('sha256', secretKey) return createHmac('sha256', secretKey)
.update(canonicalize(data), 'utf8') .update(canonicalize(data, timestamp, nonce), 'utf8')
.digest('hex') .digest('hex')
} }
@@ -127,9 +132,9 @@ export function sign(data: unknown, secretKey: string): string {
* @param secretKey The secret key. * @param secretKey The secret key.
* @returns True if the signature is valid, false otherwise. * @returns True if the signature is valid, false otherwise.
*/ */
export function verify(data: unknown, signature: string, secretKey: string): boolean { export function verify(data: unknown, timestamp: number, nonce: string, signature: string, secretKey: string): boolean {
try { try {
const computed = sign(data, secretKey) const computed = sign(data, timestamp, nonce, secretKey)
const computedBuf = Buffer.from(computed, 'hex') const computedBuf = Buffer.from(computed, 'hex')
const providedBuf = Buffer.from(signature, 'hex') const providedBuf = Buffer.from(signature, 'hex')
if (computedBuf.length !== providedBuf.length) { if (computedBuf.length !== providedBuf.length) {
+120 -23
View File
@@ -1,35 +1,46 @@
import Elysia, { t } from 'elysia' import Elysia, { HTTPHeaders, StatusMap } from 'elysia'
import { db, table as $t } from '~/db' import { db, table as $t } from '~/db'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { apiAuth } from '~/config' import { apiAuth } from '~/config'
import merchantNonces from '~/db/schema/merchant_nonces' import merchantNonces from '~/db/schema/merchant_nonces'
import { verify } from '~/helpers/signature' import { verify } from '~/helpers/signature'
import { UnauthenticatedError } from '~/helpers/errors' import { UnauthenticatedError } from '~/helpers/errors'
import { logger } from '~/plugins'
export const apiKeyAuth = new Elysia().macro({ export const apiKeyAuthMiddleware = async (
verifyKey: { headers: Record<string, string | undefined>,
headers: t.Object({ body: unknown,
'x-api-key': t.String(), set: {
'x-nonce': t.String(), headers: HTTPHeaders
'x-signature': t.String(), status?: number | keyof StatusMap
'x-timestamp': t.Number(), },
}), ) => {
body: t.Unknown(),
async beforeHandle({ headers, body }) {
const apiKey = headers['x-api-key'] const apiKey = headers['x-api-key']
const nonce = headers['x-nonce'] const nonce = headers['x-nonce']
const signature = headers['x-signature'] const signature = headers['x-signature']
const timestamp = headers['x-timestamp'] const rawTimestamp = headers['x-timestamp']
if (!apiKey || !nonce || !signature || !timestamp) { if (!apiKey || !nonce || !signature || !rawTimestamp) {
logger.debug({
'headers.authorization': headers.authorization,
}, 'Failed API key authentication attempt due to missing auth headers')
set.status = 401
throw new UnauthenticatedError('Missing auth headers') throw new UnauthenticatedError('Missing auth headers')
} }
const tsNum = Number(timestamp) const timestamp = Number(rawTimestamp)
if (!Number.isFinite(tsNum)) { if (!Number.isFinite(timestamp)) {
logger.debug({
'headers.authorization': headers.authorization,
}, 'Failed API key authentication attempt due to invalid timestamp')
set.status = 401
throw new UnauthenticatedError('Invalid timestamp') throw new UnauthenticatedError('Invalid timestamp')
} }
const nowSec = Math.floor(Date.now() / 1000) const nowSec = Math.floor(Date.now() / 1000)
if (Math.abs(nowSec - tsNum) > apiAuth.skewSeconds) { if (Math.abs(nowSec - timestamp) > apiAuth.skewSeconds) {
logger.debug({
'headers.authorization': headers.authorization,
}, 'Failed API key authentication attempt due to timestamp out of range')
set.status = 401
throw new UnauthenticatedError('Timestamp out of range') throw new UnauthenticatedError('Timestamp out of range')
} }
@@ -40,29 +51,115 @@ export const apiKeyAuth = new Elysia().macro({
), ),
}) })
if (!merchant) { if (!merchant) {
logger.debug({
'headers.authorization': headers.authorization,
}, 'Failed API key authentication attempt due to invalid API key')
set.status = 401
throw new UnauthenticatedError('Invalid API key') throw new UnauthenticatedError('Invalid API key')
} }
if (!nonce) {
throw new UnauthenticatedError('Nonce is required')
}
const existingNonce = await db.$count($t.merchantNonces, and( const existingNonce = await db.$count($t.merchantNonces, and(
eq($t.merchantNonces.merchantId, merchant.merchantId), eq($t.merchantNonces.merchantId, merchant.merchantId),
eq($t.merchantNonces.nonce, nonce), eq($t.merchantNonces.nonce, nonce),
)) ))
if (existingNonce > 0) { if (existingNonce > 0) {
throw new UnauthenticatedError('API replay detected') logger.debug({
'headers.authorization': headers.authorization,
}, 'Failed API key authentication attempt due to request replay detected')
set.status = 401
throw new UnauthenticatedError('Duplicate request detected, ensure your nonce is unique')
} }
db.insert(merchantNonces).values({ db.insert(merchantNonces).values({
merchantId: merchant.merchantId, merchantId: merchant.merchantId,
nonce, nonce,
}).catch((error) => {
logger.error({
errorMessage: error.message,
}, 'Failed to save merchant nonce into database')
}) })
if (!verify(body, signature, merchant.secretKey)) { if (!verify(body, timestamp, nonce, signature, merchant.secretKey)) {
logger.debug({
'headers.authorization': headers.authorization,
}, 'Failed API key authentication attempt due to invalid signature')
set.status = 401
throw new UnauthenticatedError('Invalid signature')
}
}
export const apiKeyAuthMacro = new Elysia().macro({
verifyKey: (enabled: boolean) => ({
async beforeHandle({ headers, body }) {
if (!enabled) {
return
}
const apiKey = headers['x-api-key']
const nonce = headers['x-nonce']
const signature = headers['x-signature']
const rawTimestamp = headers['x-timestamp']
if (!apiKey || !nonce || !signature || !rawTimestamp) {
logger.debug({
'headers.authorization': headers.authorization,
}, 'Failed API key authentication attempt due to missing auth headers')
throw new UnauthenticatedError('Missing auth headers')
}
const timestamp = Number(rawTimestamp)
if (!Number.isFinite(timestamp)) {
logger.debug({
'headers.authorization': headers.authorization,
}, 'Failed API key authentication attempt due to invalid timestamp')
throw new UnauthenticatedError('Invalid timestamp')
}
const nowSec = Math.floor(Date.now() / 1000)
if (Math.abs(nowSec - timestamp) > apiAuth.skewSeconds) {
logger.debug({
'headers.authorization': headers.authorization,
}, 'Failed API key authentication attempt due to timestamp out of range')
throw new UnauthenticatedError('Timestamp out of range')
}
const merchant = await db.query.merchants.findFirst({
where: (t, { and, eq }) => and(
eq(t.apiKey, apiKey),
eq(t.isActive, true),
),
})
if (!merchant) {
logger.debug({
'headers.authorization': headers.authorization,
}, 'Failed API key authentication attempt due to invalid API key')
throw new UnauthenticatedError('Invalid API key')
}
const existingNonce = await db.$count($t.merchantNonces, and(
eq($t.merchantNonces.merchantId, merchant.merchantId),
eq($t.merchantNonces.nonce, nonce),
))
if (existingNonce > 0) {
logger.debug({
'headers.authorization': headers.authorization,
}, 'Failed API key authentication attempt due to replay attack detected')
throw new UnauthenticatedError('Duplicate request detected, ensure your nonce is unique')
}
db.insert(merchantNonces).values({
merchantId: merchant.merchantId,
nonce,
}).catch((error) => {
logger.error({
errorMessage: error.message,
}, 'Failed to save merchant nonce into database')
})
if (!verify(body, timestamp, nonce, signature, merchant.secretKey)) {
logger.debug({
'headers.authorization': headers.authorization,
}, 'Failed API key authentication attempt due to invalid signature')
throw new UnauthenticatedError('Invalid signature') throw new UnauthenticatedError('Invalid signature')
} }
}, },
}, }),
}) })
+16 -6
View File
@@ -1,9 +1,9 @@
import { Elysia } from 'elysia' import { Elysia, t } from 'elysia'
import { createMerchantBody, createMerchantResponseSchema } from './schema' import { createMerchantBody, createMerchantResponseSchema } from './schema'
import CreateMerchantService from './commands/service' import CreateMerchantService from './commands/service'
import { basicAuthMiddleware } from '~/middlewares/basicAuth' import { basicAuthMiddleware } from '~/middlewares/basicAuth'
import { DataAlreadyExistsError } from '~/helpers/errors' import { DataAlreadyExistsError } from '~/helpers/errors'
import { apiKeyAuth } from '~/middlewares/apiKeyAuth' import { apiKeyAuthMacro } from '~/middlewares/apiKeyAuth'
export const router = new Elysia({ export const router = new Elysia({
name: 'modules.auth', name: 'modules.auth',
@@ -41,13 +41,23 @@ export const router = new Elysia({
security: [{ basicAuth: [] }], security: [{ basicAuth: [] }],
}, },
}) })
.use(apiKeyAuth) .use(apiKeyAuthMacro)
.get('/test-key', async ({ body }) => { .post('/test-key', async ({ body }) => {
return body return body
}, { }, {
verifyKey: true, verifyKey: true,
detail: { body: t.Object({
security: [{ basicAuth: [] }], foo: t.String(),
bar: t.Object({
lorem: t.Integer(),
ipsum: t.Boolean(),
}),
}),
response: {
200: t.Any(),
401: t.Object({
message: t.String(),
}),
}, },
}) })