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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/relay:scout/6-signed-like-the-forge-signs-src-sign-ts → main 1 commit fork

  • Merged. scout/6-signed-like-the-forge-signs-src-sign-ts was merged into main as 0700826.

Review

Sign in to review this change.
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 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs