diff --git a/source/auth/auth.ts b/source/auth/auth.ts index abb944f..929a243 100644 --- a/source/auth/auth.ts +++ b/source/auth/auth.ts @@ -1,110 +1,122 @@ -import type { Database } from '../services/storage/database'; +import type { Database } from '../services/storage/database.ts'; import { hexToBin, instantiateSecp256k1, type Secp256k1, sha256 } from '@bitauth/libauth'; -import { UnauthorizedError } from '../errors/unauthorized-error'; +import { UnauthorizedError } from '../errors/unauthorized-error.ts'; export type AuthSecp256k1RequiredDeps = { - database: Database; -} + database: Database; +}; export type AuthSecp256k1OptionalDeps = { - secp256k1: Secp256k1; -} + secp256k1: Secp256k1; +}; export type AuthSecp256k1Deps = AuthSecp256k1RequiredDeps & Partial; export type AuthSecp256k1Options = { - timestampWindowMs: number; -} + timestampWindowMs: number; +}; export class AuthSecp256k1 { - /** - * Create a new instance of AuthSecp256k1 - * @returns A new instance of AuthSecp256k1 - */ - static async create(inputDeps: AuthSecp256k1Deps, options: AuthSecp256k1Options): Promise { - const deps = { - secp256k1: await instantiateSecp256k1(), - ...inputDeps, + /** + * Create a new instance of AuthSecp256k1 + * @returns A new instance of AuthSecp256k1 + */ + static async create(inputDeps: AuthSecp256k1Deps, options: AuthSecp256k1Options): Promise { + const deps = { + secp256k1: await instantiateSecp256k1(), + ...inputDeps, + }; + + return new AuthSecp256k1(deps, options); } - return new AuthSecp256k1(deps, options); - } + /** + * TODO: Consider adding a Record where each key is the signature to guarantee signatures arent being processed concurrently. + */ + readonly #deps: Required; + readonly #options: AuthSecp256k1Options; - /** - * TODO: Consider adding a Record where each key is the signature to guarantee signatures arent being processed concurrently. - */ - - /** - * @param deps - The dependencies to use - * @param options - The options to use - */ - private constructor(private readonly deps: Required, private readonly options: AuthSecp256k1Options) {} - - /** - * Verify a signature - * @param publicKeyHex - The public key to verify the signature against - * @param signatureHex - The signature to verify - * @param payload - The payload to verify the signature against - * @returns Whether the signature is valid - */ - async verifySignature(publicKeyHex: string, signatureHex: string, payload: string): Promise { - return true; - // Convert the public key and signature to binary - const publicKey = hexToBin(publicKeyHex); - const signature = hexToBin(signatureHex); - - // Convert the payload to bytes and compute the sha256 hash - const payloadBytes = new TextEncoder().encode(payload); - const messageHash = sha256.hash(payloadBytes); - - // Verify the signature - const verified = await this.deps.secp256k1.verifySignatureDERLowS(signature, publicKey, messageHash); - - // If the signature is not valid, throw an unauthorized error - if (!verified) { - throw new UnauthorizedError('Invalid signature'); + /** + * @param deps - The dependencies to use + * @param options - The options to use + */ + private constructor(deps: Required, options: AuthSecp256k1Options) { + this.#deps = deps; + this.#options = options; } - // Return the verified signature - return verified; - } + /** + * Verify a signature + * @param publicKeyHex - The public key to verify the signature against + * @param signatureHex - The signature to verify + * @param payload - The payload to verify the signature against + * @returns Whether the signature is valid + */ + async verifySignature(publicKeyHex: string, signatureHex: string, payload: string): Promise { + // Convert the public key and signature to binary + const publicKey = hexToBin(publicKeyHex); + const signature = hexToBin(signatureHex); - async verifyUniqueRequest(signature: string): Promise { - if (!signature) { - throw new UnauthorizedError('Signature is required'); + // Convert the payload to bytes and compute the sha256 hash + const payloadBytes = new TextEncoder().encode(payload); + const messageHash = sha256.hash(payloadBytes); + + // Verify the signature + const verified = this.#deps.secp256k1.verifySignatureDERLowS(signature, publicKey, messageHash); + + // If the signature is not valid, throw an unauthorized error + if (!verified) { + throw new UnauthorizedError('Invalid signature'); + } + + // Return the verified signature + return verified; } - console.log('Verifying unique request', signature); + /** + * Verify a signature is unique and add it to the database if it is + * + * @param signature - The signature to verify + * @returns Whether the signature is unique + */ + async verifyUniqueRequest(signature: string): Promise { + // If the signature is not provided, throw an unauthorized error + if (!signature) { + throw new UnauthorizedError('Signature is required'); + } - // Check if the signature has been used before - const request = await this.deps.database.db. - selectFrom('authed_requests') - .selectAll() - .where('signature', '=', signature) - .executeTakeFirst(); - - if (request) { - throw new UnauthorizedError('Request already used'); + // Check if the signature has been used before + const request = await this.#deps.database.db.selectFrom('authed_requests').selectAll() +.where('signature', '=', signature) +.executeTakeFirst(); + + // If the signature has been used before, throw an unauthorized error + if (request) { + throw new UnauthorizedError('Request already used'); + } + + // Add the signature to the requests table + await this.#deps.database.db.insertInto('authed_requests').values({ signature }) +.execute(); + + // Return true if the signature is valid + return true; } - // Add the signature to the requests table - await this.deps.database.db.insertInto('authed_requests').values({ signature }).execute(); + /** + * Assert that a timestamp is within a allowed window + * @param timestamp - The timestamp to check + * @param windowMs - The window in milliseconds + * + * @throws An {@link UnauthorizedError} if the timestamp is outside the allowed window + */ + assertTimestampFreshness(timestamp: number, windowMs = this.#options.timestampWindowMs): void { + // Subtract the timestamp from the current time to get the age in milliseconds + const age = Math.abs(Date.now() - timestamp); - // Return true if the signature is valid - return true; - } - - /** - * Assert that a timestamp is within a allowed window - * @param timestamp - The timestamp to check - * @param windowMs - The window in milliseconds - * - * @throws An {@link UnauthorizedError} if the timestamp is outside the allowed window - */ - assertTimestampFreshness(timestamp: number, windowMs = this.options.timestampWindowMs): void { - const age = Math.abs(Date.now() - timestamp); - if (age > windowMs) { - throw new UnauthorizedError('Timestamp outside allowed window'); + // If the timestamp is outside the allowed window, throw an unauthorized error + if (age > windowMs) { + throw new UnauthorizedError('Timestamp outside allowed window'); + } } - } -} \ No newline at end of file +} diff --git a/source/index.ts b/source/index.ts index 47d0165..ea4c8fb 100644 --- a/source/index.ts +++ b/source/index.ts @@ -88,7 +88,10 @@ export class App { startUniqueRequestCleanup(cleanupIntervalMs: number, timestampWindowMs: number): void { // Every 10 seconds, we will cleanup the requests table setInterval(async () => { - await this.database.db.deleteFrom('authed_requests').where('timestamp', '<', Date.now() - timestampWindowMs).execute(); + await this.database.db + .deleteFrom('authed_requests') + .where('timestamp', '<', Date.now() - timestampWindowMs) + .execute(); }, cleanupIntervalMs); } } diff --git a/source/services/router.ts b/source/services/router.ts index 3ce81bc..6a90ea2 100644 --- a/source/services/router.ts +++ b/source/services/router.ts @@ -24,24 +24,30 @@ export type ApplicationRequest = { }; export type ApplicationRouterDependencies = { + /** Authentication service. */ auth: AuthSecp256k1; }; -const accountSchema = z.object({ - 'x-public-key': z.string(), - 'x-signature': z.string(), - 'x-timestamp': z.coerce.number(), -}).transform((data) => ({ - publicKey: data['x-public-key'], - signature: data['x-signature'], - timestamp: data['x-timestamp'], -})); +const accountSchema = z + .object({ + 'x-public-key': z.string(), + 'x-signature': z.string(), + 'x-timestamp': z.coerce.number(), + }) + .transform((data) => ({ + publicKey: data['x-public-key'], + signature: data['x-signature'], + timestamp: data['x-timestamp'], + })); /** Exact-match application routing shared by every wire transport. */ export class ApplicationRouter { /** @param routes - Validated route table keyed by exact path. */ - private constructor(private readonly deps: ApplicationRouterDependencies, private readonly routes: ReadonlyMap) {} + private constructor( + private readonly deps: ApplicationRouterDependencies, + private readonly routes: ReadonlyMap, + ) {} /** * Load and validate the complete route table before accepting traffic. @@ -74,11 +80,8 @@ export class ApplicationRouter { * @param connection - Shared connection stream for this transport session. */ async dispatch(request: ApplicationRequest, connection: BaseStream): Promise { + // Authenticate the headers on the request. const { publicKey, signature, timestamp } = accountSchema.parse(request.headers); - // Authenticate the headers on the request. (TODO: Remove the defaults, just here for testing) - // const publicKey = request.headers?.['x-public-key'] || 'public-key'; - // const signature = request.headers?.['x-signature'] || 'signature'; - // const timestamp = request.headers?.['x-timestamp'] || Date.now(); // Make sure the request signature is valid and hasnt been used before await this.deps.auth.verifyUniqueRequest(signature); diff --git a/source/services/storage/migrations/001-resources.ts b/source/services/storage/migrations/001-resources.ts index 9967790..0e85e23 100644 --- a/source/services/storage/migrations/001-resources.ts +++ b/source/services/storage/migrations/001-resources.ts @@ -26,7 +26,7 @@ export const up = async (db: Kysely): Promise => { .addColumn('signature', 'text', (col) => col.notNull()) .addPrimaryKeyConstraint('pk_resource_data', [ 'resource_id', 'public_key' ]) .execute(); - + // Table for authed requests // We will store the signature and the timestamp of the request, and we will clear out rows that are older than our msTimeout for our auth await db.schema @@ -46,5 +46,7 @@ export const up = async (db: Kysely): Promise => { export const down = async (db: Kysely): Promise => { await db.schema.dropTable('resource_data').ifExists() .execute(); - await db.schema.dropTable('authed_requests').ifExists().execute(); + + await db.schema.dropTable('authed_requests').ifExists() +.execute(); }; diff --git a/source/services/storage/tables.ts b/source/services/storage/tables.ts index 5241240..ee5039d 100644 --- a/source/services/storage/tables.ts +++ b/source/services/storage/tables.ts @@ -28,6 +28,7 @@ export interface ResourceDataTable { } export interface AuthedRequestsTable { + /** Signature of the request. */ signature: string; @@ -35,29 +36,6 @@ export interface AuthedRequestsTable { timestamp: Timestamp; } -// export interface PaymentsTable { -// /** Unique identifier for the payment. */ -// payment_id: string; - -// /** Public key of the account in the transaction */ -// public_key: string; - -// /** Amount of the payment. This can be positive or negative.*/ -// amount: number; - -// /** Timestamp of the payment. */ -// timestamp: Timestamp; - -// /** Signature of the payment. */ -// signature: string; - -// /** Hash of the message that was signed. */ -// message_hash: string; - -// /** Resource ID of the payment. */ -// resource_id: string; -// } - /** Complete Kysely schema mapping for the sync server database. */ export interface DatabaseTables { resource_data: ResourceDataTable; diff --git a/test/services/config.test.ts b/test/services/config.test.ts index 6100e28..eb68f46 100644 --- a/test/services/config.test.ts +++ b/test/services/config.test.ts @@ -18,7 +18,7 @@ const testConfigDefaultsTo1MiBRequestBodyLimit = (): void => { expect(config.server.host).toBe('0.0.0.0'); expect(config.server.cors.origin).toBe('*'); expect(config.server.cors.methods).toEqual([ 'GET', 'POST', 'PUT', 'DELETE', 'OPTIONS' ]); - expect(config.server.cors.allowedHeaders).toEqual([ 'Content-Type', 'cache-control', 'X-Timestamp', 'X-PublicKey', 'X-Signature' ]); + expect(config.server.cors.allowedHeaders).toEqual([ 'Content-Type', 'cache-control', 'X-Timestamp', 'X-Public-Key', 'X-Signature' ]); expect(config.auth.timestampWindowMs).toBe(300000); }; diff --git a/test/services/router.test.ts b/test/services/router.test.ts index b9f2256..ce91dcc 100644 --- a/test/services/router.test.ts +++ b/test/services/router.test.ts @@ -1,9 +1,35 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { RouteDefinition, RouteModule } from '../../source/routes/types.ts'; import { ApplicationRouter } from '../../source/services/router.ts'; import { TestConnection } from '../helpers/test-connection.ts'; +import type { AuthSecp256k1 } from '../../source/auth/auth.ts'; + +/** + * A controlled request is a request that is controlled by the test. + * It is used to control the request flow and ensure that the request is completed in the correct order. + */ +type ControlledRequest = { + request: Promise; + started: Promise; + release: () => void; +}; + +/** + * A mock of the AuthSecp256k1 service + */ +const auth = { + verifySignature: vi.fn().mockResolvedValue(true), + verifyUniqueRequest: vi.fn().mockResolvedValue(true), + assertTimestampFreshness: vi.fn().mockResolvedValue(true), +} as unknown as AuthSecp256k1; + +/** + * A helper function to create a route module with the given routes + * @param routes - The routes to create the module with + * @returns The created route module + */ const moduleWith = (routes: RouteDefinition[]): RouteModule => { return { async getRoutes(): Promise { @@ -16,75 +42,130 @@ describe('ApplicationRouter initialization', (): void => { it('rejects duplicate exact paths during startup', async (): Promise => { const route = { url: '/echo', handler: (): void => undefined }; - await expect(ApplicationRouter.create([ moduleWith([ route ]), moduleWith([ route ]) ])).rejects.toThrow('Duplicate application route: /echo'); + await expect(ApplicationRouter.create({ auth }, [ moduleWith([ route ]), moduleWith([ route ]) ])).rejects.toThrow('Duplicate application route: /echo'); }); it.each([ 'echo', '/', '/items/:id', '/items?active=true', '/items#active' ])('rejects the invalid route path %s', async (url): Promise => { - await expect(ApplicationRouter.create([ moduleWith([{ url, handler: (): void => undefined }]) ])).rejects.toThrow('Invalid application route'); + await expect(ApplicationRouter.create({ auth }, [ moduleWith([{ url, handler: (): void => undefined }]) ])).rejects.toThrow('Invalid application route'); }); }); describe('ApplicationRouter dispatch', (): void => { it('binds the connection, body, and request ID to one route stream', async (): Promise => { const connection = new TestConnection(false, false); - const router = await ApplicationRouter.create([ + const router = await ApplicationRouter.create({ auth }, [ moduleWith([ { url: '/echo', handler: async (stream): Promise => { expect(stream.connection).toBe(connection); expect(stream.path).toBe('/echo'); - expect(stream.headers).toEqual({ 'x-request-token': 'route-1' }); + expect(stream.headers).toEqual({ 'x-public-key': 'public-key', 'x-signature': 'signature', 'x-timestamp': '1000' }); await stream.send(stream.body); }, }, ]), ]); - await router.dispatch({ path: '/echo', body: { value: 1 }, requestId: 'request-1', headers: { 'x-request-token': 'route-1' } }, connection); + await router.dispatch( + { + path: '/echo', + body: { value: 1 }, + requestId: '1', + headers: { 'x-public-key': 'public-key', 'x-signature': 'signature', 'x-timestamp': '1000' }, + }, + connection, + ); expect(connection.messages).toEqual([ { - id: 'request-1', + id: '1', type: 'response', statusCode: 200, body: { value: 1 }, }, ]); - await expect(router.dispatch({ path: '/echo/other', body: {} }, connection)).rejects.toMatchObject({ statusCode: 404 }); + await expect(router.dispatch( + { path: '/echo/other', body: {}, headers: { 'x-public-key': 'public-key', 'x-signature': 'signature', 'x-timestamp': '1000' } }, + connection, + )).rejects.toMatchObject({ statusCode: 404 }); }); it('preserves correlation when concurrent requests finish out of order', async (): Promise => { - const completions = new Map void>(); - const router = await ApplicationRouter.create([ + const router = await ApplicationRouter.create({ auth }, [ moduleWith([ { url: '/delayed', handler: async (stream): Promise => { - const key = (stream.body as { key: string }).key; - const token = stream.headers['x-request-token']; - await new Promise((resolve) => completions.set(key, resolve)); - await stream.send({ key, token }); + const { key, signalStarted, released } = stream.body as { + key: string; + signalStarted: () => void; + released: Promise; + }; + + signalStarted(); + + await released; + await stream.send({ key }); }, }, ]), ]); - const connection = new TestConnection(true, true); - const first = router.dispatch( - { path: '/delayed', body: { key: 'A' }, requestId: 'A', headers: { 'x-request-token': 'token-a' } }, - connection, - ); - const second = router.dispatch( - { path: '/delayed', body: { key: 'B' }, requestId: 'B', headers: { 'x-request-token': 'token-b' } }, - connection, - ); + const connection = new TestConnection(false, false); - completions.get('B')?.(); - await second; - completions.get('A')?.(); - await first; + const createControlledRequest = (key: string): ControlledRequest => { + const { promise: started, resolve: signalStarted } = Promise.withResolvers(); + + const { promise: released, resolve: release } = Promise.withResolvers(); + + const request = router.dispatch( + { + path: '/delayed', + body: { + key, + signalStarted, + released, + }, + requestId: key, + headers: { + 'x-public-key': 'public-key', + 'x-signature': 'signature', + 'x-timestamp': '1000', + }, + }, + connection, + ); + + return { + request, + started, + release, + }; + }; + + const first = createControlledRequest('A'); + const second = createControlledRequest('B'); + + expect(connection.messages).toEqual([]); + + await Promise.all([ first.started, second.started ]); + + second.release(); + await second.request; + + expect(connection.messages).toEqual([ + { + id: 'B', + type: 'response', + statusCode: 200, + body: { key: 'B' }, + }, + ]); + + first.release(); + await first.request; expect(connection.messages).toEqual([ { @@ -104,7 +185,7 @@ describe('ApplicationRouter dispatch', (): void => { it('propagates route failures without infrastructure-specific cleanup', async (): Promise => { const error = new Error('route failed'); - const router = await ApplicationRouter.create([ + const router = await ApplicationRouter.create({ auth }, [ moduleWith([ { url: '/failure', @@ -115,6 +196,9 @@ describe('ApplicationRouter dispatch', (): void => { ]), ]); - await expect(router.dispatch({ path: '/failure' }, new TestConnection(false, false))).rejects.toBe(error); + await expect(router.dispatch( + { path: '/failure', headers: { 'x-public-key': 'public-key', 'x-signature': 'signature', 'x-timestamp': '1000' } }, + new TestConnection(false, false), + )).rejects.toBe(error); }); }); diff --git a/tsconfig.json b/tsconfig.json index 6e15aa7..0db5f25 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,8 +2,8 @@ "compilerOptions": { "rootDir": "./source", "outDir": "./dist", - "module": "es2022", - "target": "es2022", + "module": "esnext", + "target": "esnext", "skipLibCheck": true, "strict": true, "moduleResolution": "bundler", @@ -15,5 +15,5 @@ "declarationMap": true, "types": ["node"] }, - "exclude": ["node_modules/**/*", "dist/**/*"] + "exclude": ["node_modules/**/*", "dist/**/*", "test/**/*"] }