src / protocol.ts
src / protocol.ts
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
// Every message in both directions is an envelope {p, s}: p is the JSON payload as a
// string and s is HMAC-SHA256(secret, p) in hex. Signing the exact string avoids any
// mismatch from re-serializing JSON on the other side.
export const MAX_SKEW_MS = 120_000;
export interface Message {
type: string;
id: string;
ts: number;
[key: string]: unknown;
}
export function hmacHex(secret: string, data: string): string {
return createHmac("sha256", secret).update(data, "utf8").digest("hex");
}
export function seal(secret: string, payload: Record<string, unknown>): string {
const full = { ...payload, id: typeof payload.id === "string" ? payload.id : randomUUID(), ts: Date.now() };
const p = JSON.stringify(full);
return JSON.stringify({ p, s: hmacHex(secret, p) });
}
// Rejects stale messages and any message id seen before.
export class ReplayGuard {
private seen = new Map<string, number>();
check(id: string, ts: number): boolean {
const now = Date.now();
if (Math.abs(now - ts) > MAX_SKEW_MS) return false;
if (this.seen.has(id)) return false;
this.seen.set(id, ts);
if (this.seen.size > 5000) {
for (const [key, value] of this.seen) {
if (now - value > MAX_SKEW_MS) this.seen.delete(key);
}
}
return true;
}
}
export function unseal(secret: string, raw: string, guard: ReplayGuard): Message | null {
let envelope: unknown;
try {
envelope = JSON.parse(raw);
} catch {
return null;
}
const env = envelope as { p?: unknown; s?: unknown };
if (!env || typeof env.p !== "string" || typeof env.s !== "string") return null;
if (!/^[0-9a-f]{64}$/i.test(env.s)) return null;
const expected = Buffer.from(hmacHex(secret, env.p), "hex");
const got = Buffer.from(env.s, "hex");
if (expected.length !== got.length || !timingSafeEqual(expected, got)) return null;
let msg: unknown;
try {
msg = JSON.parse(env.p);
} catch {
return null;
}
const m = msg as Partial<Message>;
if (!m || typeof m.id !== "string" || typeof m.ts !== "number" || typeof m.type !== "string") return null;
if (!guard.check(m.id, m.ts)) return null;
return m as Message;
}
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
// Every message in both directions is an envelope {p, s}: p is the JSON payload as a
// string and s is HMAC-SHA256(secret, p) in hex. Signing the exact string avoids any
// mismatch from re-serializing JSON on the other side.
export const MAX_SKEW_MS = 120_000;
export interface Message {
type: string;
id: string;
ts: number;
[key: string]: unknown;
}
export function hmacHex(secret: string, data: string): string {
return createHmac("sha256", secret).update(data, "utf8").digest("hex");
}
export function seal(secret: string, payload: Record<string, unknown>): string {
const full = { ...payload, id: typeof payload.id === "string" ? payload.id : randomUUID(), ts: Date.now() };
const p = JSON.stringify(full);
return JSON.stringify({ p, s: hmacHex(secret, p) });
}
// Rejects stale messages and any message id seen before.
export class ReplayGuard {
private seen = new Map<string, number>();
check(id: string, ts: number): boolean {
const now = Date.now();
if (Math.abs(now - ts) > MAX_SKEW_MS) return false;
if (this.seen.has(id)) return false;
this.seen.set(id, ts);
if (this.seen.size > 5000) {
for (const [key, value] of this.seen) {
if (now - value > MAX_SKEW_MS) this.seen.delete(key);
}
}
return true;
}
}
export function unseal(secret: string, raw: string, guard: ReplayGuard): Message | null {
let envelope: unknown;
try {
envelope = JSON.parse(raw);
} catch {
return null;
}
const env = envelope as { p?: unknown; s?: unknown };
if (!env || typeof env.p !== "string" || typeof env.s !== "string") return null;
if (!/^[0-9a-f]{64}$/i.test(env.s)) return null;
const expected = Buffer.from(hmacHex(secret, env.p), "hex");
const got = Buffer.from(env.s, "hex");
if (expected.length !== got.length || !timingSafeEqual(expected, got)) return null;
let msg: unknown;
try {
msg = JSON.parse(env.p);
} catch {
return null;
}
const m = msg as Partial<Message>;
if (!m || typeof m.id !== "string" || typeof m.ts !== "number" || typeof m.type !== "string") return null;
if (!guard.check(m.id, m.ts)) return null;
return m as Message;
}