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
+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. */