From 5bf51df052fc05c5dc3a74eb98698c0361bd39e6 Mon Sep 17 00:00:00 2001 From: Christopher Brady Date: Tue, 27 Oct 2026 10:21:59 -0600 Subject: [PATCH] add a context signing helper --- .fernignore | 3 + README.md | 31 +++ src/context-signature.ts | 155 +++++++++++++++ src/index.ts | 9 + tests/unit/context-signature.test.ts | 104 ++++++++++ tests/unit/context-signing.json | 284 +++++++++++++++++++++++++++ 6 files changed, 586 insertions(+) create mode 100644 src/context-signature.ts create mode 100644 tests/unit/context-signature.test.ts create mode 100644 tests/unit/context-signing.json diff --git a/.fernignore b/.fernignore index ff80b8dd..f345883f 100644 --- a/.fernignore +++ b/.fernignore @@ -17,6 +17,7 @@ conformance/ scripts/ testapp/ src/cache/ +src/context-signature.ts src/core/fetcher/custom.ts src/credits/ src/datastream/ @@ -35,6 +36,8 @@ tests/conformance/ tests/integration/ tests/races/ tests/unit/cache/local.test.ts +tests/unit/context-signature.test.ts +tests/unit/context-signing.json tests/unit/credits/ tests/unit/datastream/datastream-client.test.ts tests/unit/datastream/redis-key-layout.test.ts diff --git a/README.md b/README.md index f74a8b8d..fe9283c7 100644 --- a/README.md +++ b/README.md @@ -649,6 +649,37 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } ``` +## Context Signatures + +A browser SDK uses a publishable key, which anyone can read from your page, so on its own it can't prove which company or user it's acting for. When an environment requires context signatures, the API only accepts a publishable-key request whose company and user your backend has signed with the environment's secret API key. The browser SDK asks your backend for the signature through its `getContextSignature` option and sends it in the `X-Schematic-Context-Sig` header. + +### Signing a context + +Sign the logged-in user's own company and user, read from your session. Don't sign whatever company or user the browser asks for, or anyone could get a signature for another company. + +```ts +import express from "express"; +import { signContext } from "@schematichq/schematic-typescript-node"; + +const app = express(); + +app.post("/api/schematic/sign", (req, res) => { + const session = getSession(req); // however your app identifies the logged-in user + if (!session) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const signature = signContext(process.env.SCHEMATIC_SECRET_KEY!, { + company: { id: session.companyId }, + user: { id: session.userId }, + }); + + res.json({ signature }); +}); +``` + +The company and user keys must match the context you give the browser SDK exactly, or the API rejects the signature. A signature is valid for 10 minutes by default; set a different lifetime, up to an hour, with the `lifetimeSeconds` option. The browser SDK reuses a signature until shortly before it expires, then asks for a new one. `signContext` throws a `ContextSignatureError` when the secret is missing, the lifetime is out of range, or a key's value isn't a string. + ## Caching ### Local Caching diff --git a/src/context-signature.ts b/src/context-signature.ts new file mode 100644 index 00000000..ae13089c --- /dev/null +++ b/src/context-signature.ts @@ -0,0 +1,155 @@ +/** + * Signs the context a browser SDK sends with a publishable key, for + * environments that require context signatures. + * + * A publishable key is public, so the API can't trust the company and user a + * browser names with it. When the environment requires signatures, your + * backend, which holds the secret API key, signs the logged-in user's own + * company and user, and the browser SDK sends that signature in the + * X-Schematic-Context-Sig header. Sign the context from your own authenticated + * session, never from what the browser asks for. + * + * The signature is `iat.exp.sig`, where `sig` is the unpadded base64url + * HMAC-SHA256 of `canonical(context).iat.exp` keyed with the secret API key, + * and `canonical` is the context's JSON with keys sorted by code point and no + * whitespace. + */ + +import * as crypto from "crypto"; + +/** + * Request header the browser SDK sends the signature in. + */ +export const CONTEXT_SIGNATURE_HEADER = "X-Schematic-Context-Sig"; + +/** + * How long a signature is valid for unless `lifetimeSeconds` says otherwise. + * It matches how long the browser SDK reuses one. + */ +export const DEFAULT_CONTEXT_SIGNATURE_LIFETIME_SECONDS = 600; + +/** + * The longest the API accepts a signature as valid for. + */ +export const MAX_CONTEXT_SIGNATURE_LIFETIME_SECONDS = 3600; + +/** + * The company and user a browser SDK checks flags and sends events for, by + * their keys. Leave one out when it's absent; an empty object is signed as + * empty. It must match the context the browser SDK is given exactly. + */ +export interface SignableContext { + company?: Record; + user?: Record; +} + +export interface SignContextOptions { + /** + * When the signature is valid from; now by default. Counted in whole + * seconds. + */ + issuedAt?: Date; + /** + * How long the signature is valid for, in whole seconds, from 1 up to + * MAX_CONTEXT_SIGNATURE_LIFETIME_SECONDS. + */ + lifetimeSeconds?: number; +} + +/** + * Error thrown when a context can't be signed. + */ +export class ContextSignatureError extends Error { + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, ContextSignatureError.prototype); + this.name = "ContextSignatureError"; + } +} + +/** + * Returns the X-Schematic-Context-Sig value for a context. + * @param secret - The secret API key for the environment the browser SDK's publishable key belongs to. + * @param context - The logged-in user's company and user keys. + * @param options - When the signature is valid from, and for how long. + * @throws {ContextSignatureError} If the secret is missing, the lifetime is out of range, or a key's value isn't a string. + */ +export function signContext(secret: string, context: SignableContext, options: SignContextOptions = {}): string { + if (!secret) { + throw new ContextSignatureError("A secret API key is required to sign a context."); + } + + const lifetime = options.lifetimeSeconds ?? DEFAULT_CONTEXT_SIGNATURE_LIFETIME_SECONDS; + if (!Number.isInteger(lifetime) || lifetime < 1 || lifetime > MAX_CONTEXT_SIGNATURE_LIFETIME_SECONDS) { + throw new ContextSignatureError( + `lifetimeSeconds must be a whole number from 1 to ${MAX_CONTEXT_SIGNATURE_LIFETIME_SECONDS}.`, + ); + } + + const issuedAt = options.issuedAt ?? new Date(); + const iat = Math.floor(issuedAt.getTime() / 1000); + if (!Number.isFinite(iat)) { + throw new ContextSignatureError("issuedAt must be a valid date."); + } + + return signContextAt(secret, context, iat, iat + lifetime); +} + +/** + * Signs a context for an explicit `iat` and `exp`, in unix seconds. Exported + * for tests; use signContext. + * @internal + */ +export function signContextAt(secret: string, context: SignableContext, iat: number, exp: number): string { + const payload = `${canonicalizeContext(context)}.${iat}.${exp}`; + const sig = crypto.createHmac("sha256", secret).update(payload, "utf8").digest("base64url"); + return `${iat}.${exp}.${sig}`; +} + +/** + * Encodes a context the way the API does before verifying it: keys sorted by + * code point, no whitespace, `<`, `>`, `&` and `/` left literal, and U+2028 and + * U+2029 escaped. + * @internal + */ +export function canonicalizeContext(context: SignableContext): string { + const parts: string[] = []; + // "company" sorts before "user". + if (context.company != null) { + parts.push(`"company":${canonicalizeKeys(context.company)}`); + } + if (context.user != null) { + parts.push(`"user":${canonicalizeKeys(context.user)}`); + } + return `{${parts.join(",")}}`; +} + +function canonicalizeKeys(keys: Record): string { + const names = Object.keys(keys).sort(compareCodePoints); + const entries = names.map((name) => { + const value = keys[name]; + if (typeof value !== "string") { + throw new ContextSignatureError(`The value of context key "${name}" must be a string.`); + } + return `${encodeString(name)}:${encodeString(value)}`; + }); + return `{${entries.join(",")}}`; +} + +// The API sorts keys by their UTF-8 bytes, which is code point order. The +// default sort compares UTF-16 code units, which puts a character outside the +// BMP (a surrogate pair) before U+E000 to U+FFFF. +function compareCodePoints(a: string, b: string): number { + return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); +} + +const loneSurrogate = /[\ud800-\udbff](?![\udc00-\udfff])|(? { + it("caps the lifetime where the API does", () => { + expect(MAX_CONTEXT_SIGNATURE_LIFETIME_SECONDS).toBe(vectors.max_lifetime_seconds); + }); + + it.each(vectors.vectors.map((v) => [v.name, v] as const))("%s", (_name, v) => { + expect(canonicalizeContext(v.context)).toBe(v.canonical); + expect(signContextAt(v.secret, v.context, v.iat, v.exp)).toBe(v.signature); + }); +}); + +describe("signContext", () => { + const context: SignableContext = { company: { id: "comp_1" } }; + + it("signs for the default lifetime from now", () => { + const before = Math.floor(Date.now() / 1000); + const { iat, exp } = window(signContext("secret", context)); + + expect(iat).toBeGreaterThanOrEqual(before); + expect(iat).toBeLessThanOrEqual(Math.floor(Date.now() / 1000)); + expect(exp - iat).toBe(DEFAULT_CONTEXT_SIGNATURE_LIFETIME_SECONDS); + }); + + it("signs from the time and for the lifetime given", () => { + const issuedAt = new Date(1_700_000_000_500); + const signature = signContext("secret", context, { issuedAt, lifetimeSeconds: 300 }); + + expect(signature).toBe(signContextAt("secret", context, 1_700_000_000, 1_700_000_300)); + }); + + it.each([ + 0, + -60, + 1.5, + MAX_CONTEXT_SIGNATURE_LIFETIME_SECONDS + 1, + 86_400, + ])("refuses a lifetime of %p seconds", (lifetimeSeconds) => { + expect(() => signContext("secret", context, { lifetimeSeconds })).toThrow(ContextSignatureError); + }); + + it("refuses an invalid issuedAt", () => { + expect(() => signContext("secret", context, { issuedAt: new Date(Number.NaN) })).toThrow(ContextSignatureError); + }); + + it("refuses a missing secret", () => { + expect(() => signContext("", context)).toThrow(ContextSignatureError); + }); + + it("refuses a value that isn't a string", () => { + const numeric = { company: { id: 123 } } as unknown as SignableContext; + expect(() => signContext("secret", numeric)).toThrow(ContextSignatureError); + }); +}); + +describe("canonicalizeContext", () => { + it("leaves out a company or user that is null or undefined", () => { + const context = { company: null, user: undefined } as unknown as SignableContext; + expect(canonicalizeContext(context)).toBe("{}"); + }); + + it("signs a lone surrogate as U+FFFD, as the API reads it", () => { + expect(canonicalizeContext({ company: { name: "a\ud800b" } })).toBe('{"company":{"name":"a\ufffdb"}}'); + expect(canonicalizeContext({ company: { name: "a\udc00" } })).toBe('{"company":{"name":"a\ufffd"}}'); + }); + + it("keeps a valid surrogate pair", () => { + expect(canonicalizeContext({ company: { name: "\ud83d\ude00" } })).toBe('{"company":{"name":"\ud83d\ude00"}}'); + }); +}); diff --git a/tests/unit/context-signing.json b/tests/unit/context-signing.json new file mode 100644 index 00000000..5ade5a85 --- /dev/null +++ b/tests/unit/context-signing.json @@ -0,0 +1,284 @@ +{ + "category": "context_signing", + "description": "Vectors for signing a Schematic context, generated from the API's verifier (api/lib/contextsig) and checked by its tests. A signing helper must produce `canonical` from `context`, then `signature` from `canonical`, `secret`, `iat` and `exp`, byte for byte. A context is an object with optional `company` and `user`, each a flat map of string keys to string values; omit either when it's absent rather than sending null. A helper must refuse to sign when `exp - iat` exceeds `max_lifetime_seconds`. The wire format is described in docs/docs/ADRs/context-signatures.md. When an implementation diverges, add a vector here rather than special-casing one implementation.", + "max_lifetime_seconds": 3600, + "vectors": [ + { + "name": "empty_context", + "description": "A context with neither company nor user signs as an empty object.", + "context": {}, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{}", + "signature": "1700000000.1700000600.dHOxXm5b92k2zvYhI0SQ-Fiob9S40pxnuuO0af5m-bc" + }, + { + "name": "company_only", + "description": "Only the company is set; user is omitted entirely, not sent as null.", + "context": { + "company": { + "id": "comp_1" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"id\":\"comp_1\"}}", + "signature": "1700000000.1700000600.4Kp-nByFvw81ya-bGvfIfxCZ01CFJsLuvpv7PExMn-g" + }, + { + "name": "user_only", + "description": "Only the user is set.", + "context": { + "user": { + "id": "user_1" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"user\":{\"id\":\"user_1\"}}", + "signature": "1700000000.1700000600.TtaAnqrSGcoZWyyNEpirmq9GpAgmSmlfVNqtXzSKmCI" + }, + { + "name": "user_inserted_before_company", + "description": "Top-level keys sort, so insertion order doesn't matter.", + "context": { + "user": { + "id": "user_1" + }, + "company": { + "id": "comp_1" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"id\":\"comp_1\"},\"user\":{\"id\":\"user_1\"}}", + "signature": "1700000000.1700000600.yeQ3kB3C0WzlSDXlN2KRI0b02erLakr-zT2ran9eSJo" + }, + { + "name": "multiple_keys_unsorted", + "description": "Keys inside company and user sort too.", + "context": { + "company": { + "plan_id": "plan_1", + "id": "comp_1" + }, + "user": { + "role": "admin", + "email": "a@example.com" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"id\":\"comp_1\",\"plan_id\":\"plan_1\"},\"user\":{\"email\":\"a@example.com\",\"role\":\"admin\"}}", + "signature": "1700000000.1700000600.9agSoQf9J1KVQ5aC-mAKWSstw0ukgHNKFbpdi_0Vm1k" + }, + { + "name": "empty_company", + "description": "An empty keys map is kept as {}.", + "context": { + "company": {}, + "user": { + "id": "user_1" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{},\"user\":{\"id\":\"user_1\"}}", + "signature": "1700000000.1700000600.lrpWxfGjqyZrlEIqHqEqW0weySvK14Qtlf6RwssLZQ0" + }, + { + "name": "keys_sort_by_byte_uppercase_first", + "description": "Keys sort by UTF-8 bytes, not case-insensitively: 'B' (0x42) < '_' (0x5F) < 'a' (0x61).", + "context": { + "company": { + "a": "1", + "_": "2", + "B": "3" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"B\":\"3\",\"_\":\"2\",\"a\":\"1\"}}", + "signature": "1700000000.1700000600.0tso_81DIOtAlItlg2BLCQMTOYeEeTX_GijMjJ63LtU" + }, + { + "name": "keys_sort_shorter_prefix_first", + "description": "A key that is a prefix of another sorts first; '2' (0x32) sorts before '_' (0x5F).", + "context": { + "company": { + "id_2": "c", + "id2": "b", + "id": "a" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"id\":\"a\",\"id2\":\"b\",\"id_2\":\"c\"}}", + "signature": "1700000000.1700000600.PRBTr8382ZV5XvWaEaFAHXBzN1ekP2a-Oai-sOFEvxY" + }, + { + "name": "keys_sort_by_code_point_not_utf16", + "description": "Keys sort by code point (equivalently UTF-8 bytes). UTF-16 order, the default string sort in JavaScript, Java and C#, puts the emoji (surrogate 0xD83D) before U+FF5E; code-point order puts U+FF5E (0xFF5E) before the emoji (0x1F600).", + "context": { + "company": { + "😀": "emoji", + "~": "fullwidth tilde" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"~\":\"fullwidth tilde\",\"😀\":\"emoji\"}}", + "signature": "1700000000.1700000600.OHfc8vQolXTC35whD2P4r77OwgI1LrsszSYczJXOfzk" + }, + { + "name": "empty_key_and_value", + "description": "Empty strings are valid keys and values; the empty key sorts first.", + "context": { + "company": { + "id": "", + "": "x" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"\":\"x\",\"id\":\"\"}}", + "signature": "1700000000.1700000600.I_EAwzsawe_Jrq01CTstVhYk9mrQx18-z3oG-WagVNE" + }, + { + "name": "digit_string_stays_string", + "description": "A value made of digits is still a string and keeps its quotes.", + "context": { + "company": { + "id": "12345" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"id\":\"12345\"}}", + "signature": "1700000000.1700000600.oQyoPgb4BrXfS4XgpEUNg-go-Un6aG1d3MhUGopWbeQ" + }, + { + "name": "html_characters_unescaped", + "description": "'<', '>' and '&' appear literally, not as \\u003c, \\u003e, \\u0026.", + "context": { + "company": { + "name": "Tom & Jerry" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"name\":\"Tom & Jerry\"}}", + "signature": "1700000000.1700000600.kFKnwy-dtdRT3HPD5X2r_Ft-ToXEU40kZNNIRHPGhOE" + }, + { + "name": "solidus_unescaped", + "description": "'/' appears literally, not as '\\/'.", + "context": { + "company": { + "url": "https://example.com/a/b" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"url\":\"https://example.com/a/b\"}}", + "signature": "1700000000.1700000600.aIu-pdUIc2GrWRUYjB2Ly36SQGz_QhUlRFYRosDHJMY" + }, + { + "name": "quote_and_backslash_escaped", + "description": "'\"' and '\\' are escaped with a backslash.", + "context": { + "company": { + "name": "say \"hi\" \\ bye" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"name\":\"say \\\"hi\\\" \\\\ bye\"}}", + "signature": "1700000000.1700000600.iCLZi3k6QgO7-WDoYBpuGeqiSrtzcBKpmYNQ1m-D3Lw" + }, + { + "name": "control_characters_escaped", + "description": "Control characters below U+0020 are escaped: \\b, \\f, \\n, \\r and \\t by name, the rest as lowercase \\u00XX. DEL (U+007F) is not escaped.", + "context": { + "company": { + "note": "a\nb\rc\td\be\ff\u0000g\u001fh\u007fi" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"note\":\"a\\nb\\rc\\td\\be\\ff\\u0000g\\u001fh\u007fi\"}}", + "signature": "1700000000.1700000600.1RSPKFMOqM2H7Od15cDeBnerUNgl7RCsodWudl8KKjg" + }, + { + "name": "line_separators_escaped", + "description": "U+2028 and U+2029 are escaped as \\u2028 and \\u2029. JSON.stringify, Python's json.dumps and most other encoders leave them raw.", + "context": { + "company": { + "note": "a\u2028b\u2029c" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"note\":\"a\\u2028b\\u2029c\"}}", + "signature": "1700000000.1700000600.tbU1MgYS9wXPnGr9amA62M1FvaMxt7-OXS_4axnP2fA" + }, + { + "name": "non_ascii_raw", + "description": "Other non-ASCII characters appear as raw UTF-8, never as \\u escapes; that includes characters outside the BMP, which are not written as surrogate pairs.", + "context": { + "company": { + "name": "Café 日本 😀" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"name\":\"Café 日本 😀\"}}", + "signature": "1700000000.1700000600.H6liuTTYNtLZaPqBhyUUw5tMp8d4ot2yHjfpZnTqcpk" + }, + { + "name": "other_secret", + "description": "The same context signed with a different secret gives a different signature.", + "context": { + "company": { + "id": "comp_1" + } + }, + "secret": "test-secret-2", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"id\":\"comp_1\"}}", + "signature": "1700000000.1700000600.4eEK8qmJmv3l1kYXii_XXe2Cy4NuARgAG6XkThUBqQg" + }, + { + "name": "max_lifetime", + "description": "exp may be at most max_lifetime_seconds after iat; this signature is valid for exactly that long.", + "context": { + "company": { + "id": "comp_1" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700003600, + "canonical": "{\"company\":{\"id\":\"comp_1\"}}", + "signature": "1700000000.1700003600.wZrgH4Y3MJWGbFIdN26tz9HRg4wbWmM89ao5kgbazUw" + } + ] +}