Many fixes
This commit is contained in:
+1
-1
@@ -23,7 +23,7 @@ export class AuthSecp256k1 {
|
||||
*/
|
||||
static async create(inputDeps: AuthSecp256k1Deps, options: AuthSecp256k1Options): Promise<AuthSecp256k1> {
|
||||
const deps = {
|
||||
secp256k1: await instantiateSecp256k1(),
|
||||
secp256k1: inputDeps.secp256k1 || await instantiateSecp256k1(),
|
||||
...inputDeps,
|
||||
};
|
||||
|
||||
|
||||
+7
-1
@@ -32,7 +32,13 @@ export class App {
|
||||
const routes = [
|
||||
// DataRoute owns resource read/write/subscribe logic and maps resource
|
||||
// ids to broadcaster topics. timestampWindowMs controls write replay protection.
|
||||
new DataRoute(database, broadcaster, config.auth.timestampWindowMs),
|
||||
new DataRoute({
|
||||
auth: auth,
|
||||
database: database,
|
||||
broadcaster: broadcaster,
|
||||
}, {
|
||||
timestampWindowMs: config.auth.timestampWindowMs,
|
||||
}),
|
||||
];
|
||||
|
||||
// Route loading is an explicit startup phase, not first-request work.
|
||||
|
||||
+24
-47
@@ -1,5 +1,3 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { hexToBin, instantiateSecp256k1, type Secp256k1 } from '@bitauth/libauth';
|
||||
import { toExtendedJson } from '@xo-cash/utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -9,6 +7,7 @@ import type { BaseBroadcaster } from '../services/broadcaster.ts';
|
||||
import { ApplicationError, UnauthorizedError } from '../errors/index.ts';
|
||||
import type { Database } from '../services/storage/database.ts';
|
||||
import type { RouteDefinition, RouteModule, RouteStream } from './types.ts';
|
||||
import { AuthSecp256k1 } from '../auth/auth.ts';
|
||||
|
||||
/**
|
||||
* Schema to validate a single write resource.
|
||||
@@ -47,6 +46,16 @@ const resourceIdsSchema = z.object({
|
||||
|
||||
type WriteResource = z.infer<typeof writeResource>;
|
||||
|
||||
export type DataRouteDeps = {
|
||||
auth: AuthSecp256k1;
|
||||
database: Database;
|
||||
broadcaster: BaseBroadcaster;
|
||||
}
|
||||
|
||||
export type DataRouteOptions = {
|
||||
timestampWindowMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource data domain routes.
|
||||
*
|
||||
@@ -56,16 +65,9 @@ type WriteResource = z.infer<typeof writeResource>;
|
||||
* RouteStream API.
|
||||
*/
|
||||
export class DataRoute implements RouteModule {
|
||||
/**
|
||||
* Promise to instantiate the secp256k1 library
|
||||
* This is a bit annoying, but keeping a single instance alive makes more sense than instantiating it for each verification.
|
||||
*/
|
||||
private readonly secp256k1Promise: Promise<Secp256k1> = instantiateSecp256k1();
|
||||
|
||||
constructor(
|
||||
private readonly database: Database,
|
||||
private readonly broadcaster: BaseBroadcaster,
|
||||
private readonly timestampWindowMs: number,
|
||||
private readonly deps: DataRouteDeps,
|
||||
private readonly options: DataRouteOptions,
|
||||
) {}
|
||||
|
||||
/** Declare exact routes; each handler owns its stream behavior. */
|
||||
@@ -105,7 +107,7 @@ export class DataRoute implements RouteModule {
|
||||
}
|
||||
|
||||
// Read the data from the database.
|
||||
const rows = await this.database.db
|
||||
const rows = await this.deps.database.db
|
||||
.selectFrom('resource_data')
|
||||
.select([ 'resource_id', 'public_key', 'blob', 'timestamp' ])
|
||||
.where('resource_id', 'in', uniqueIds)
|
||||
@@ -140,17 +142,19 @@ export class DataRoute implements RouteModule {
|
||||
const rows = resources.map((resource) => ({
|
||||
resourceId: resource.id,
|
||||
publicKey: resource.publicKey,
|
||||
signature: resource.signature,
|
||||
blob: Buffer.from(resource.value),
|
||||
}));
|
||||
|
||||
// Upsert every row atomically; conflicts update blob and timestamp only.
|
||||
await this.database.db.transaction().execute(async (trx) => {
|
||||
for (const { resourceId, publicKey, blob } of rows) {
|
||||
await this.deps.database.db.transaction().execute(async (trx) => {
|
||||
for (const { resourceId, publicKey, blob, signature } of rows) {
|
||||
await trx
|
||||
.insertInto('resource_data')
|
||||
.values({
|
||||
resource_id: resourceId,
|
||||
public_key: publicKey,
|
||||
signature: signature,
|
||||
blob,
|
||||
timestamp,
|
||||
})
|
||||
@@ -176,7 +180,7 @@ export class DataRoute implements RouteModule {
|
||||
// Notify subscribers on each changed resource. Topic names are scoped per
|
||||
// resource id so clients only receive events for resources they joined.
|
||||
for (const { resourceId, instance } of written) {
|
||||
await this.broadcaster.publish(DataRoute.resourceTopic(resourceId), {
|
||||
await this.deps.broadcaster.publish(DataRoute.resourceTopic(resourceId), {
|
||||
type: 'instance-changed',
|
||||
data: { resourceId, ...instance },
|
||||
});
|
||||
@@ -199,7 +203,7 @@ export class DataRoute implements RouteModule {
|
||||
|
||||
// Subscribe registers the topics synchronously, then keeps this route
|
||||
// active until those topics are removed or the connection closes.
|
||||
await this.broadcaster.subscribe(stream, topics);
|
||||
await this.deps.broadcaster.subscribe(stream, topics);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,7 +221,7 @@ export class DataRoute implements RouteModule {
|
||||
}
|
||||
|
||||
// Unsubscribe from the resource topics.
|
||||
await this.broadcaster.unsubscribe(
|
||||
await this.deps.broadcaster.unsubscribe(
|
||||
stream,
|
||||
resourceIds.map((resourceId) => DataRoute.resourceTopic(resourceId)),
|
||||
);
|
||||
@@ -241,7 +245,7 @@ export class DataRoute implements RouteModule {
|
||||
private async verifyWriteResource(resource: WriteResource): Promise<void> {
|
||||
this.assertFreshTimestamp(resource.timestamp);
|
||||
|
||||
if (!(await this.verifySignature(resource.publicKey, resource.signature, DataRoute.canonicalWritePayload(resource)))) {
|
||||
if (!(await this.deps.auth.verifySignature(resource.publicKey, resource.signature, DataRoute.canonicalWritePayload(resource)))) {
|
||||
throw new UnauthorizedError('Invalid resource signature');
|
||||
}
|
||||
}
|
||||
@@ -253,38 +257,11 @@ export class DataRoute implements RouteModule {
|
||||
*/
|
||||
private assertFreshTimestamp(timestamp: number): void {
|
||||
const age = Math.abs(Date.now() - timestamp);
|
||||
if (age > this.timestampWindowMs) {
|
||||
if (age > this.options.timestampWindowMs) {
|
||||
throw new UnauthorizedError('Timestamp outside allowed window');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a secp256k1 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.
|
||||
*/
|
||||
private async verifySignature(publicKeyHex: string, signatureHex: string, payload: string): Promise<boolean> {
|
||||
const secp256k1 = await this.secp256k1Promise;
|
||||
|
||||
try {
|
||||
// Convert the public key and signature to binary.
|
||||
const publicKey = hexToBin(publicKeyHex);
|
||||
const signature = hexToBin(signatureHex);
|
||||
|
||||
// Create a SHA-256 hash of the payload.
|
||||
const messageHash = createHash('sha256').update(payload)
|
||||
.digest();
|
||||
|
||||
// Low-S normalization rejects malleable signature encodings.
|
||||
return secp256k1.verifySignatureDERLowS(signature, publicKey, messageHash);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the transport-independent payload authenticated by each signature.
|
||||
*
|
||||
@@ -292,7 +269,7 @@ export class DataRoute implements RouteModule {
|
||||
* whether the client sent them over HTTP or WebSocket.
|
||||
*/
|
||||
private static canonicalWritePayload(resource: WriteResource): string {
|
||||
return `${resource.timestamp}${resource.id}${toExtendedJson(resource.value)}`;
|
||||
return `${resource.timestamp}:${resource.id}:${toExtendedJson(resource.value)}`;
|
||||
}
|
||||
|
||||
/** Broadcaster topic for a single resource's instance-changed events. */
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Hono } from "hono";
|
||||
import { RequestHeaders } from "../../routes/types";
|
||||
import { ApplicationError } from "../../errors";
|
||||
import { HTTP_STATUS_CODE_BAD_REQUEST } from "../../constants";
|
||||
|
||||
/** RFC 9110 field-name token grammar. */
|
||||
const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
||||
|
||||
/** Hono variables populated by transport-boundary middleware. */
|
||||
export type AppEnv = {
|
||||
Variables: {
|
||||
|
||||
/** Decoded Extended JSON request body, when present. */
|
||||
parsedBody?: unknown;
|
||||
|
||||
/** Raw JSON text preserved for signature verification. */
|
||||
rawJsonBody?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export abstract class BaseTransport {
|
||||
/** Attach wire endpoints and middleware to the shared Hono application. */
|
||||
abstract register(app: Hono<AppEnv>): void
|
||||
|
||||
/** Close transport-owned long-lived connections during server shutdown. */
|
||||
abstract stop(): Promise<void>
|
||||
|
||||
/**
|
||||
* Validate and normalize request headers at a transport boundary.
|
||||
*
|
||||
* Lowercase names give HTTP and WebSocket routes identical lookup semantics.
|
||||
* Case-insensitive duplicates are rejected instead of selecting an ambiguous
|
||||
* authentication value. Header values may not contain line breaks.
|
||||
*/
|
||||
public static normalizeRequestHeaders(headers: Readonly<Record<string, string>>): RequestHeaders {
|
||||
const normalizedEntries: Array<[string, string]> = [];
|
||||
const names = new Set<string>();
|
||||
|
||||
for (const [ name, value ] of Object.entries(headers)) {
|
||||
if (!HEADER_NAME_PATTERN.test(name)) {
|
||||
throw new ApplicationError(HTTP_STATUS_CODE_BAD_REQUEST, 'Invalid request header name');
|
||||
}
|
||||
|
||||
if (value.includes('\r') || value.includes('\n')) {
|
||||
throw new ApplicationError(HTTP_STATUS_CODE_BAD_REQUEST, 'Invalid request header value');
|
||||
}
|
||||
|
||||
const normalizedName = name.toLowerCase();
|
||||
if (names.has(normalizedName)) {
|
||||
throw new ApplicationError(HTTP_STATUS_CODE_BAD_REQUEST, 'Duplicate request header name');
|
||||
}
|
||||
|
||||
names.add(normalizedName);
|
||||
normalizedEntries.push([ normalizedName, value ]);
|
||||
}
|
||||
|
||||
return Object.freeze(Object.fromEntries(normalizedEntries));
|
||||
};
|
||||
}
|
||||
@@ -5,12 +5,12 @@ import { toExtendedJson, fromExtendedJson } from '@xo-cash/utils';
|
||||
import type { Logger } from '../../utils/logger.ts';
|
||||
import type { ApplicationRequest, ApplicationRouter } from '../router.ts';
|
||||
import type { StreamResponse } from '../stream/base-stream.ts';
|
||||
import type { AppEnv, TransportRouter } from './transport-router.ts';
|
||||
|
||||
import { ApplicationError, normalizePublicError } from '../../errors/index.ts';
|
||||
import { HTTP_STATUS_CODE_BAD_REQUEST, HTTP_STATUS_CODE_NO_CONTENT } from '../../constants.ts';
|
||||
import { HonoSSEStream } from '../stream/hono-sse-stream.ts';
|
||||
import { HttpRequestStream } from '../stream/http-request-stream.ts';
|
||||
import { type AppEnv, BaseTransport } from './base-transport.ts';
|
||||
|
||||
/** Hono context key where decoded Extended JSON bodies are stored. */
|
||||
const PARSED_BODY_KEY = 'parsedBody';
|
||||
@@ -21,7 +21,7 @@ const PARSED_BODY_KEY = 'parsedBody';
|
||||
* Normal HTTP and SSE both enter the same application router with different
|
||||
* connection-stream capabilities.
|
||||
*/
|
||||
export class HttpTransportRouter implements TransportRouter {
|
||||
export class HttpTransportRouter extends BaseTransport {
|
||||
private readonly debug: Logger;
|
||||
|
||||
/** SSE connections retained until their final subscription or peer closes. */
|
||||
@@ -35,6 +35,8 @@ export class HttpTransportRouter implements TransportRouter {
|
||||
private readonly router: ApplicationRouter,
|
||||
debug: Logger,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.debug = debug.extend('http-transport');
|
||||
}
|
||||
|
||||
@@ -111,6 +113,7 @@ export class HttpTransportRouter implements TransportRouter {
|
||||
return {
|
||||
path: context.req.path,
|
||||
...(body === undefined ? {} : { body }),
|
||||
headers: context.req.header(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@ import { z } from 'zod';
|
||||
|
||||
import type { Logger } from '../../utils/logger.ts';
|
||||
import type { ApplicationRequest, ApplicationRouter } from '../router.ts';
|
||||
import type { AppEnv, UpgradeTransportRouter } from './transport-router.ts';
|
||||
|
||||
import { ApplicationError, normalizePublicError } from '../../errors/index.ts';
|
||||
import { HTTP_STATUS_CODE_BAD_REQUEST } from '../../constants.ts';
|
||||
import { WSStream } from '../stream/ws-stream.ts';
|
||||
import { type AppEnv, BaseTransport } from './base-transport.ts';
|
||||
|
||||
/** Default WebSocket upgrade path for application messages. */
|
||||
const WS_ROUTE = '/ws';
|
||||
@@ -33,7 +33,7 @@ const wsRequestSchema = z
|
||||
* One WSStream is shared by every message on a socket, allowing subscribe and
|
||||
* unsubscribe requests to operate on the same broadcaster registration.
|
||||
*/
|
||||
export class WsTransportRouter implements UpgradeTransportRouter {
|
||||
export class WsTransportRouter extends BaseTransport {
|
||||
/**
|
||||
* Native Node WebSocket server used by Hono's Node adapter.
|
||||
*
|
||||
@@ -42,9 +42,6 @@ export class WsTransportRouter implements UpgradeTransportRouter {
|
||||
*/
|
||||
private readonly wsServer: WebSocketServer;
|
||||
|
||||
/** Upgrade server wired into the Node HTTP listener by ServerHost. */
|
||||
readonly websocketServer: WebSocketServerLike;
|
||||
|
||||
private readonly debug: Logger;
|
||||
|
||||
/**
|
||||
@@ -59,6 +56,8 @@ export class WsTransportRouter implements UpgradeTransportRouter {
|
||||
private readonly maxRequestBodyBytes: number,
|
||||
private readonly url: string = WS_ROUTE,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.debug = debug.extend('ws-transport');
|
||||
|
||||
// Hono's Node WebSocket helper delegates frame handling to `ws`; unlike
|
||||
@@ -68,7 +67,6 @@ export class WsTransportRouter implements UpgradeTransportRouter {
|
||||
noServer: true,
|
||||
maxPayload: this.maxRequestBodyBytes,
|
||||
});
|
||||
this.websocketServer = this.wsServer as unknown as WebSocketServerLike;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user