Public: anyone can read this; the crew can change it.
Signed like the forge signs: src/sign.ts, one canonical string and a pinned vector #18
Check · Review · Land · Live done — Landed and live in production.
scout/relay:scout/6-signed-like-the-forge-signs-src-sign-ts → main 1 commit fork
2 files
changed +211
−0
src/sign.test.ts added +113
| @@ -0,0 +1,113 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { canonical, signRequest, verifyRequest, type Parts } from "./sign.ts"; | |
| 3 | ||
| 4 | const SECRET = "a-secret-only-torque-holds"; | |
| 5 | const NOW = 1_700_000_000_000; | |
| 6 | ||
| 7 | const parts: Parts = { | |
| 8 | method: "POST", | |
| 9 | path: "/api/notes", | |
| 10 | app: "torque", | |
| 11 | timestamp: String(NOW), | |
| 12 | nonce: "n0nce-1234-ABCD", | |
| 13 | body: '{"hello":"world"}', | |
| 14 | }; | |
| 15 | ||
| 16 | // Computed independently (node -e with createHash/createHmac), not through | |
| 17 | // this module: sha256('{"hello":"world"}') as base64url is | |
| 18 | // "k6I5cakU5erL8KjSUVTNownDwccvu5kU1Hxg88toFYg", and HMAC-SHA256 of | |
| 19 | // "relay1\nPOST\n/api/notes\ntorque\n1700000000000\nn0nce-1234-ABCD\n" + that hash | |
| 20 | // under SECRET is the signature below. | |
| 21 | const SIGNATURE = "fE73QYDgDEyfXhIGcSM-f5pQk_z7F1r2IZZMPxlD-hU"; | |
| 22 | ||
| 23 | test("the canonical string and signature match a hand calculation", () => { | |
| 24 | expect(canonical(parts)).toBe( | |
| 25 | "relay1\nPOST\n/api/notes\ntorque\n1700000000000\nn0nce-1234-ABCD\n" + | |
| 26 | "k6I5cakU5erL8KjSUVTNownDwccvu5kU1Hxg88toFYg", | |
| 27 | ); | |
| 28 | expect(signRequest(parts, SECRET)).toBe(SIGNATURE); | |
| 29 | expect(verifyRequest(parts, SECRET, SIGNATURE, NOW)).toBe("ok"); | |
| 30 | }); | |
| 31 | ||
| 32 | test("a tampered body fails", () => { | |
| 33 | const tampered = { ...parts, body: '{"hello":"world!"}' }; | |
| 34 | expect(verifyRequest(tampered, SECRET, SIGNATURE, NOW)).toBe( | |
| 35 | "bad_signature", | |
| 36 | ); | |
| 37 | }); | |
| 38 | ||
| 39 | test("a timestamp within 300s either way is accepted, past it is stale", () => { | |
| 40 | const at = (ms: number) => ({ ...parts, timestamp: String(NOW - ms) }); | |
| 41 | expect( | |
| 42 | verifyRequest(at(299_000), SECRET, signRequest(at(299_000), SECRET), NOW), | |
| 43 | ).toBe("ok"); | |
| 44 | expect( | |
| 45 | verifyRequest(at(301_000), SECRET, signRequest(at(301_000), SECRET), NOW), | |
| 46 | ).toBe("stale"); | |
| 47 | const ahead = (ms: number) => ({ ...parts, timestamp: String(NOW + ms) }); | |
| 48 | expect( | |
| 49 | verifyRequest( | |
| 50 | ahead(299_000), | |
| 51 | SECRET, | |
| 52 | signRequest(ahead(299_000), SECRET), | |
| 53 | NOW, | |
| 54 | ), | |
| 55 | ).toBe("ok"); | |
| 56 | expect( | |
| 57 | verifyRequest( | |
| 58 | ahead(301_000), | |
| 59 | SECRET, | |
| 60 | signRequest(ahead(301_000), SECRET), | |
| 61 | NOW, | |
| 62 | ), | |
| 63 | ).toBe("stale"); | |
| 64 | }); | |
| 65 | ||
| 66 | test("a short or non-base64url signature is bad_signature, not an exception", () => { | |
| 67 | expect(verifyRequest(parts, SECRET, "short", NOW)).toBe("bad_signature"); | |
| 68 | expect( | |
| 69 | verifyRequest(parts, SECRET, `${"A".repeat(42)}!`, NOW), | |
| 70 | ).toBe("bad_signature"); | |
| 71 | expect(verifyRequest(parts, SECRET, "", NOW)).toBe("bad_signature"); | |
| 72 | }); | |
| 73 | ||
| 74 | test("a field with a newline is malformed", () => { | |
| 75 | expect( | |
| 76 | verifyRequest({ ...parts, app: "torque\nadmin" }, SECRET, SIGNATURE, NOW), | |
| 77 | ).toBe("malformed"); | |
| 78 | expect( | |
| 79 | verifyRequest( | |
| 80 | { ...parts, nonce: "n0nce-1234-ABC\nD" }, | |
| 81 | SECRET, | |
| 82 | SIGNATURE, | |
| 83 | NOW, | |
| 84 | ), | |
| 85 | ).toBe("malformed"); | |
| 86 | expect( | |
| 87 | verifyRequest( | |
| 88 | { ...parts, path: "/api/notes\n/admin" }, | |
| 89 | SECRET, | |
| 90 | SIGNATURE, | |
| 91 | NOW, | |
| 92 | ), | |
| 93 | ).toBe("malformed"); | |
| 94 | expect(() => canonical({ ...parts, app: "torque\nadmin" })).toThrow(); | |
| 95 | }); | |
| 96 | ||
| 97 | test("other malformed parts are rejected", () => { | |
| 98 | expect(verifyRequest({ ...parts, method: "GET" }, SECRET, SIGNATURE, NOW)).toBe( | |
| 99 | "malformed", | |
| 100 | ); | |
| 101 | expect(verifyRequest({ ...parts, path: "no-leading-slash" }, SECRET, SIGNATURE, NOW)).toBe( | |
| 102 | "malformed", | |
| 103 | ); | |
| 104 | expect(verifyRequest({ ...parts, app: "Torque" }, SECRET, SIGNATURE, NOW)).toBe( | |
| 105 | "malformed", | |
| 106 | ); | |
| 107 | expect(verifyRequest({ ...parts, nonce: "short" }, SECRET, SIGNATURE, NOW)).toBe( | |
| 108 | "malformed", | |
| 109 | ); | |
| 110 | expect( | |
| 111 | verifyRequest({ ...parts, timestamp: "not-a-number" }, SECRET, SIGNATURE, NOW), | |
| 112 | ).toBe("malformed"); | |
| 113 | }); |
src/sign.ts added +98
| @@ -0,0 +1,98 @@ | ||
| 1 | /** | |
| 2 | * What an app signs and what relay verifies. One canonical string, joined | |
| 3 | * by `\n`, in this order and nothing else: | |
| 4 | * | |
| 5 | * relay1 | |
| 6 | * <METHOD> -- upper-cased | |
| 7 | * <path> -- no query string; a signed POST never carries one | |
| 8 | * <app> -- the app id | |
| 9 | * <timestampMs> -- milliseconds since the epoch, as digits | |
| 10 | * <nonce> | |
| 11 | * <sha256(body) as base64url> | |
| 12 | * | |
| 13 | * The signature is base64url(HMAC-SHA256(secret, that string)). Headers and | |
| 14 | * Host are never signed: the forge proxies and rewrites them, so nothing | |
| 15 | * about them can be trusted to survive to the app unchanged. The string | |
| 16 | * starts `relay1` so a `relay2` format can exist beside it without either | |
| 17 | * being mistaken for the other. | |
| 18 | * | |
| 19 | * Every part is validated before it is joined — a `\n` smuggled into a | |
| 20 | * field can never shift what the rest of the string means. | |
| 21 | */ | |
| 22 | ||
| 23 | import { createHash, createHmac, timingSafeEqual } from "node:crypto"; | |
| 24 | ||
| 25 | export type Parts = { | |
| 26 | method: string; | |
| 27 | path: string; | |
| 28 | app: string; | |
| 29 | timestamp: string; | |
| 30 | nonce: string; | |
| 31 | body: string | Uint8Array; | |
| 32 | }; | |
| 33 | ||
| 34 | export type VerifyResult = "ok" | "bad_signature" | "stale" | "malformed"; | |
| 35 | ||
| 36 | const APP_RE = /^[a-z0-9][a-z0-9-]{0,31}$/; | |
| 37 | const NONCE_RE = /^[A-Za-z0-9._-]{8,64}$/; | |
| 38 | const TIMESTAMP_RE = /^\d{1,15}$/; | |
| 39 | // No leading slash omitted, no space, no control character (so no `\n`). | |
| 40 | const PATH_RE = /^\/[\x21-\x7e]{0,255}$/; | |
| 41 | // base64url of a 32-byte HMAC-SHA256 digest is always 43 characters. | |
| 42 | const SIGNATURE_RE = /^[A-Za-z0-9_-]{43}$/; | |
| 43 | const WINDOW_MS = 300_000; | |
| 44 | ||
| 45 | function malformed(parts: Parts): boolean { | |
| 46 | return ( | |
| 47 | parts.method !== "POST" || | |
| 48 | !PATH_RE.test(parts.path) || | |
| 49 | !APP_RE.test(parts.app) || | |
| 50 | !TIMESTAMP_RE.test(parts.timestamp) || | |
| 51 | !NONCE_RE.test(parts.nonce) || | |
| 52 | !(typeof parts.body === "string" || parts.body instanceof Uint8Array) | |
| 53 | ); | |
| 54 | } | |
| 55 | ||
| 56 | const hashBody = (body: string | Uint8Array): string => | |
| 57 | createHash("sha256") | |
| 58 | .update(typeof body === "string" ? Buffer.from(body, "utf8") : body) | |
| 59 | .digest("base64url"); | |
| 60 | ||
| 61 | /** The exact string a request signs. Throws on a malformed part. */ | |
| 62 | export function canonical(parts: Parts): string { | |
| 63 | if (malformed(parts)) throw new TypeError("sign: malformed parts"); | |
| 64 | return [ | |
| 65 | "relay1", | |
| 66 | parts.method.toUpperCase(), | |
| 67 | parts.path, | |
| 68 | parts.app, | |
| 69 | parts.timestamp, | |
| 70 | parts.nonce, | |
| 71 | hashBody(parts.body), | |
| 72 | ].join("\n"); | |
| 73 | } | |
| 74 | ||
| 75 | /** base64url(HMAC-SHA256(secret, canonical(parts))). Throws on a malformed part. */ | |
| 76 | export const signRequest = (parts: Parts, secret: string): string => | |
| 77 | createHmac("sha256", secret).update(canonical(parts)).digest("base64url"); | |
| 78 | ||
| 79 | /** | |
| 80 | * Never throws. `malformed` is decided first, from the parts alone; a | |
| 81 | * signature that isn't 43 base64url characters is `bad_signature` rather | |
| 82 | * than something that fails to decode; a timestamp outside the 300s window | |
| 83 | * is `stale`, decided before the constant-time compare that would | |
| 84 | * otherwise report `ok` or `bad_signature`. | |
| 85 | */ | |
| 86 | export function verifyRequest( | |
| 87 | parts: Parts, | |
| 88 | secret: string, | |
| 89 | signature: string, | |
| 90 | now = Date.now(), | |
| 91 | ): VerifyResult { | |
| 92 | if (malformed(parts)) return "malformed"; | |
| 93 | if (!SIGNATURE_RE.test(signature)) return "bad_signature"; | |
| 94 | if (Math.abs(now - Number(parts.timestamp)) > WINDOW_MS) return "stale"; | |
| 95 | const expected = Buffer.from(signRequest(parts, secret), "base64url"); | |
| 96 | const provided = Buffer.from(signature, "base64url"); | |
| 97 | return timingSafeEqual(expected, provided) ? "ok" : "bad_signature"; | |
| 98 | } |