Many fixes
This commit is contained in:
+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. */
|
||||
|
||||
Reference in New Issue
Block a user