Many fixes

This commit is contained in:
2026-09-14 08:00:08 +00:00
parent 93b012592b
commit 2d970d3123
15 changed files with 338 additions and 176 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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(),
};
}
+4 -6
View File
@@ -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;
}
/**