From c2d36009a325d5e00789bc4caa259bcc38170cf6 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Fri, 7 Aug 2026 15:17:53 -0400 Subject: [PATCH] oops, just return a fake signature --- apps/img/README.md | 32 +++-- apps/img/src/context.ts | 9 +- apps/img/src/img.app.ts | 139 +++++++++++++++++----- apps/img/src/test/integration/api.test.ts | 63 +++++++--- apps/img/wrangler.jsonc | 12 +- 5 files changed, 187 insertions(+), 68 deletions(-) diff --git a/apps/img/README.md b/apps/img/README.md index c930033..b0980b0 100644 --- a/apps/img/README.md +++ b/apps/img/README.md @@ -13,25 +13,31 @@ key: back to the bundled `static/DefaultProfileImage.jpg` asset (served `200` via the `ASSETS` binding), so clients always get a valid image. The fallback also honours `?sig=p1` and returns a `Content-Signature` header. -- `GET /?sig=p1` — same, but the response body is RSA-SHA1 signed and the - signature returned in a `Content-Signature: key-id=KEY:RSA:p1.rec.net; data=` - header. The client uses this to verify image integrity. **Off by default** — - see below. +- `GET /?sig=p1` — same, plus a + `Content-Signature: key-id=KEY:RSA:p1.rec.net; data=` header. By default + that value is a **placeholder, not a real signature** — see below. The bucket is bound in the Worker as `env.IMAGES` (see `wrangler.jsonc`). ## Response signing -Signing is behind the `IMG_SIGNING_ENABLED` var in `wrangler.jsonc`, and it is -**`false`**. It is the worker's dominant CPU cost: signing has to buffer the -whole object into the isolate rather than streaming it out of R2, then hash the -full body with SHA-1 and run an RSA-2048 private-key operation — on every request -the edge cache misses. Nothing verifies the header today, so with the flag off -`?sig=p1` is accepted and ignored, and untransformed images stream through -untouched. +The client requires a `Content-Signature` header to be present when it asks for +`?sig=p1`, but it never verifies the value. Signing for real is this worker's +dominant CPU cost: it has to buffer the whole object into the isolate rather than +streaming it out of R2, then hash the full body with SHA-1 and run an RSA-2048 +private-key operation — on every request the edge cache misses. -Flip the var to `true` to turn it back on. (Resizes still buffer regardless — the -Photon codec needs the whole image.) +So by default (`IMG_SIGNING_ENABLED: false` in `wrangler.jsonc`) the header is +filled with a placeholder derived from the object key: FNV-1a seeds an xorshift32 +PRNG that emits 256 bytes, the length of a real RSA-2048 signature, so the value +is structurally indistinguishable to the client's parser and stable for a given +key. It costs no body access, so untransformed images keep streaming. + +Set the var to `true` for genuine RSA-SHA1 signatures over the returned bytes. +Note this is a **placeholder, not a downgrade of a security control** — nothing +in the system authenticates images either way. Turn it on before relying on the +header for integrity. (Resizes buffer regardless — the Photon codec needs the +whole image.) ### Signing key diff --git a/apps/img/src/context.ts b/apps/img/src/context.ts index 4ca08d5..0b0f302 100644 --- a/apps/img/src/context.ts +++ b/apps/img/src/context.ts @@ -20,10 +20,11 @@ export type Env = SharedHonoEnv & { */ IMG_SIGNING_KEY?: string /** - * Feature flag for response signing. `?sig=p1` is honoured only when this is - * true; when false (the default) the query param is ignored and the response - * streams unsigned. Off by default because signing is this worker's dominant - * CPU cost — see the `?sig=p1` handling in `img.app.ts`. + * Feature flag for REAL response signing. `?sig=p1` always returns a + * `Content-Signature` header, but only when this is true is the value an + * actual RSA-SHA1 signature over the body; when false (the default) it is a + * cheap placeholder derived from the object key, which keeps the response on + * the streaming path. See `stubSignature()` in `img.app.ts`. */ IMG_SIGNING_ENABLED?: boolean } diff --git a/apps/img/src/img.app.ts b/apps/img/src/img.app.ts index 68a2547..87e5e77 100644 --- a/apps/img/src/img.app.ts +++ b/apps/img/src/img.app.ts @@ -152,17 +152,88 @@ async function signImage(env: Env, bytes: BufferSource): Promise return btoa(binary) } +/** Length of an RSA-2048 signature, matched by the placeholder below. */ +const SIGNATURE_BYTES = 256 + +/** + * A placeholder `Content-Signature` value derived from the object key. + * + * The client requires the header to be PRESENT when it asks for `?sig=p1` — it + * does not check the value — and a real signature is this worker's dominant CPU + * cost, so by default we fabricate one. Being a pure function of the key it needs + * no access to the body, which is the whole point: the response still streams out + * of R2 instead of being buffered into the isolate to be hashed. + * + * FNV-1a over the key seeds an xorshift32 PRNG that fills a full RSA-2048-length + * signature, so the value looks structurally right and is stable for a given key + * (a cached response and a fresh one agree). It is NOT verifiable: turn on + * `IMG_SIGNING_ENABLED` if anything ever needs to check it. + */ +function stubSignature(key: string): string { + let state = 0x811c9dc5 + for (let i = 0; i < key.length; i++) { + state = Math.imul(state ^ key.charCodeAt(i), 0x01000193) >>> 0 + } + // xorshift32 is a fixed point at zero; the FNV basis makes this unreachable in + // practice, but a degenerate all-zero signature is worth ruling out outright. + if (state === 0) state = 0x811c9dc5 + + let binary = '' + for (let i = 0; i < SIGNATURE_BYTES; i++) { + state = (state ^ (state << 13)) >>> 0 + state = state ^ (state >>> 17) + state = (state ^ (state << 5)) >>> 0 + binary += String.fromCharCode(state & 0xff) + } + return btoa(binary) +} + +/** + * How this request's `Content-Signature` header gets produced. + * + * - `none` — no `?sig=p1` was asked for; no header. + * - `stub` — the value is a pure function of the object key and is already + * computed, so the body never has to be read. The default. + * - `rsa` — a real RSA-SHA1 signature over the bytes actually returned, which + * forces the whole body through the isolate. + */ +type Signing = { mode: 'none' } | { mode: 'stub'; value: string } | { mode: 'rsa' } + +function resolveSigning(env: Env, sig: string | undefined, key: string): Signing { + if (sig !== 'p1') return { mode: 'none' } + if (env.IMG_SIGNING_ENABLED === true) return { mode: 'rsa' } + return { mode: 'stub', value: stubSignature(key) } +} + +function signatureHeader(value: string): string { + return `key-id=${SIGNATURE_KEY_ID}; data=${value}` +} + +/** + * Apply the key-derived placeholder signature, if that's the mode in play. Called + * before the body is touched — a stub never forces buffering. + */ +function applyStubSignature(headers: Headers, signing: Signing): void { + if (signing.mode === 'stub') headers.set('content-signature', signatureHeader(signing.value)) +} + +/** Whether serving this response requires the full body in the isolate. */ +function needsBody(transform: Transform | null, signing: Signing): boolean { + return transform !== null || signing.mode === 'rsa' +} + /** * Given the full image bytes and prepared response `headers`, optionally resize - * (Photon) and/or RSA-SHA1 sign (`?sig=p1`) before returning the `Response`. - * Both operations need the whole body, so callers buffer before calling this. + * (Photon) and/or RSA-SHA1 sign before returning the `Response`. Both operations + * need the whole body, so callers buffer before calling this. A `stub` signature + * is already on `headers` by this point. */ async function finalizeImage( env: Env, bytes: ArrayBuffer, headers: Headers, transform: Transform | null, - wantsSignature: boolean + signing: Signing ): Promise { let body: BufferSource = bytes if (transform) { @@ -172,11 +243,9 @@ async function finalizeImage( headers.delete('etag') } - if (wantsSignature) { + if (signing.mode === 'rsa') { const signature = await signImage(env, body) - if (signature) { - headers.set('content-signature', `key-id=${SIGNATURE_KEY_ID}; data=${signature}`) - } + if (signature) headers.set('content-signature', signatureHeader(signature)) } return new Response(body, { headers }) @@ -184,23 +253,25 @@ async function finalizeImage( /** * Serve a static asset `Response` with our standard cache headers, honouring - * `?width`/`?height` (resize) and `?sig=p1` (signing). Either requires the full - * body, so the asset is buffered; otherwise it is streamed through untouched. + * `?width`/`?height` (resize) and `?sig=p1` (signing). A transform or a real + * signature requires the full body, so the asset is buffered; otherwise it is + * streamed through untouched. */ async function serveStaticAsset( env: Env, asset: Response, transform: Transform | null, - wantsSignature: boolean + signing: Signing ): Promise { const headers = new Headers() const contentType = asset.headers.get('content-type') if (contentType) headers.set('content-type', contentType) headers.set('cache-control', CACHE_CONTROL) + applyStubSignature(headers, signing) - if (transform || wantsSignature) { + if (needsBody(transform, signing)) { const bytes = await asset.arrayBuffer() - return finalizeImage(env, bytes, headers, transform, wantsSignature) + return finalizeImage(env, bytes, headers, transform, signing) } return new Response(asset.body, { headers }) @@ -256,9 +327,9 @@ app.get( 'as the fallback when a key is missing. Keys with an extension come from the', '`recflare-img` bucket; extensionless ones are `storage` uploads and come from the', 'shared `recflare-cdn` bucket under its `image/` prefix. Optional center-crop and resize', - 'run through the Photon WASM codec; `?sig=p1` adds the RSA-SHA1 `Content-Signature`', - 'header the client verifies against `KEY:RSA:p1.rec.net`, when the', - '`IMG_SIGNING_ENABLED` flag is on (it is off by default).', + 'run through the Photon WASM codec; `?sig=p1` adds the `Content-Signature` header', + 'the client expects against `KEY:RSA:p1.rec.net` — a key-derived placeholder', + 'unless the `IMG_SIGNING_ENABLED` flag turns on real RSA-SHA1 signing.', '', 'Note that this worker only serves bytes: the image metadata the client lists (the', '`SavedImage` records behind `/api/images/...`) lives in the `api` worker, which', @@ -344,12 +415,13 @@ app.get( in: 'query', required: false, description: [ - '`p1` RSA-SHA1 signs the response body and returns it as', - '`Content-Signature: key-id=KEY:RSA:p1.rec.net; data=`. Signed over the', - 'bytes actually returned, i.e. the resized body when a transform applies.', - 'Signing is off by default (it costs the streaming fast path): the param is', - 'ignored, and no header returned, unless the worker sets `IMG_SIGNING_ENABLED`', - 'and has an `IMG_SIGNING_KEY`.', + '`p1` returns a `Content-Signature: key-id=KEY:RSA:p1.rec.net; data=`', + 'header. By default `data` is a PLACEHOLDER derived from the object key, not a', + 'real signature — the client requires the header to be present but does not', + 'verify it, and signing for real costs the streaming fast path. Set', + '`IMG_SIGNING_ENABLED` for a true RSA-SHA1 signature over the bytes actually', + 'returned (i.e. the resized body when a transform applies); that also needs an', + '`IMG_SIGNING_KEY`, without which the header is omitted entirely.', ].join(' '), schema: { type: 'string', enum: ['p1'] }, }, @@ -372,12 +444,11 @@ app.get( const key = c.req.param('key') if (key.includes('..')) return c.body(null, 400) - // Signing is this worker's dominant CPU cost: it forces the whole object - // through the isolate (`arrayBuffer()` instead of streaming `object.body`) - // and pays a SHA-1 over the full body plus an RSA-2048 private-key operation - // on every edge-cache miss. Nothing verifies the header today, so `?sig=p1` - // is ignored unless IMG_SIGNING_ENABLED turns it back on. - const wantsSignature = c.env.IMG_SIGNING_ENABLED === true && c.req.query('sig') === 'p1' + // `?sig=p1` always answers with a Content-Signature header — the client needs + // one to be there — but by default the value is a cheap placeholder derived + // from the key rather than a real RSA-SHA1 signature over the body. See + // stubSignature(); IMG_SIGNING_ENABLED switches back to real signing. + const signing = resolveSigning(c.env, c.req.query('sig'), key) const transform = parseTransform( c.req.query('width'), c.req.query('height'), @@ -389,7 +460,7 @@ app.get( // that always win over whatever, if anything, is in the bucket. const staticAsset = await c.env.ASSETS.fetch(new URL(`/${key}`, c.req.url)) if (staticAsset.ok) { - return serveStaticAsset(c.env, staticAsset, transform, wantsSignature) + return serveStaticAsset(c.env, staticAsset, transform, signing) } // Conditional requests only make sense for the untransformed object: a @@ -404,9 +475,9 @@ app.get( if (!object) { // Missing from both static and R2 → serve the bundled DefaultProfileImage.jpg // static asset so clients still get a valid image instead of a 404. Honour - // `?sig=p1` the same way so signed clients can verify the fallback. + // `?sig=p1` the same way so the fallback is signed like any other image. const asset = await c.env.ASSETS.fetch(new URL(FALLBACK_ASSET_PATH, c.req.url)) - return serveStaticAsset(c.env, asset, transform, wantsSignature) + return serveStaticAsset(c.env, asset, transform, signing) } const headers = new Headers() @@ -417,9 +488,13 @@ app.get( // Precondition matched (If-None-Match) → R2 returns no body. if (!('body' in object)) return new Response(null, { status: 304, headers }) - if (transform || wantsSignature) { + // Set after the 304 above so both signing modes behave alike: the header only + // ever rides a response that actually carries bytes. + applyStubSignature(headers, signing) + + if (needsBody(transform, signing)) { const bytes = await object.arrayBuffer() - return finalizeImage(c.env, bytes, headers, transform, wantsSignature) + return finalizeImage(c.env, bytes, headers, transform, signing) } return new Response(object.body, { headers }) diff --git a/apps/img/src/test/integration/api.test.ts b/apps/img/src/test/integration/api.test.ts index bac9fcd..43b4460 100644 --- a/apps/img/src/test/integration/api.test.ts +++ b/apps/img/src/test/integration/api.test.ts @@ -38,6 +38,18 @@ const R2_KEY = 'user-photo.jpg' // upload — served from `recflare-cdn` under `image/`, not `recflare-img`. const CDN_NAME = '2028-06-01/12345-67890-12345' +/** + * Fetch with real signing turned OFF — the deployed default. `vitest.config.ts` + * binds `IMG_SIGNING_ENABLED` on so the RSA path stays covered, so the placeholder + * path has to drive the app directly with an overridden env. + */ +async function unsignedFetch(url: string): Promise { + const ctx = createExecutionContext() + const res = await app.fetch(new Request(url), { ...env, IMG_SIGNING_ENABLED: false }, ctx) + await waitOnExecutionContext(ctx) + return res +} + beforeAll(async () => { await env.IMAGES.put(R2_KEY, IMAGE_BYTES, { httpMetadata: { contentType: 'image/jpeg' }, @@ -188,25 +200,48 @@ describe('img endpoints', () => { expect(res.headers.get('content-signature')).toBeNull() }) - it('ignores ?sig=p1 when IMG_SIGNING_ENABLED is off', async () => { - // The deployed default (see wrangler.jsonc): the param is accepted but does - // nothing, so the object streams straight from R2 instead of being buffered, - // hashed and RSA-signed. Vitest binds the flag ON, so override it here. - const ctx = createExecutionContext() - const res = await app.fetch( - new Request(`${ORIGIN}/${R2_KEY}?sig=p1`), - { ...env, IMG_SIGNING_ENABLED: false }, - ctx - ) - await waitOnExecutionContext(ctx) - + it('returns a placeholder signature when IMG_SIGNING_ENABLED is off', async () => { + // The deployed default (see wrangler.jsonc). The header must still be there — + // the client requires it — but the value is derived from the key, so the body + // is neither buffered nor hashed and streams straight out of R2. + const res = await unsignedFetch(`${ORIGIN}/${R2_KEY}?sig=p1`) expect(res.status).toBe(200) - expect(res.headers.get('content-signature')).toBeNull() - // Unsigned responses keep the source etag, and the body is untouched. + + const header = res.headers.get('content-signature') + expect(header).toMatch(/^key-id=KEY:RSA:p1\.rec\.net; data=/) + // Same shape as a real RSA-2048 signature, so the client's parser sees no + // difference between the two modes. + const signature = Uint8Array.from(atob(header!.split('data=')[1]), (ch) => ch.charCodeAt(0)) + expect(signature.length).toBe(256) + expect(signature.some((b) => b !== 0)).toBe(true) + + // Still on the streaming path: the source etag survives and the bytes are the + // stored object, untouched. expect(res.headers.get('etag')).toBeTruthy() expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES) }) + it('derives the placeholder signature from the key, stably', async () => { + const sigFor = async (path: string) => + (await unsignedFetch(`${ORIGIN}/${path}?sig=p1`)).headers.get('content-signature') + + // Stable for a key, so a cached response and a fresh one agree... + expect(await sigFor(R2_KEY)).toBe(await sigFor(R2_KEY)) + // ...and distinct across keys, so it isn't a single hardcoded constant. + expect(await sigFor(R2_KEY)).not.toBe(await sigFor(CDN_NAME)) + }) + + it('signs the fallback and resized bodies with a placeholder too', async () => { + // The fallback (missing key) and the transform path both go through + // serveStaticAsset/finalizeImage — the header must survive both. + const fallback = await unsignedFetch(`${ORIGIN}/missing.png?sig=p1`) + expect(fallback.headers.get('content-signature')).toMatch(/^key-id=KEY:RSA:p1\.rec\.net; /) + + const resized = await unsignedFetch(`${ORIGIN}/RecCenter.jpg?width=512&sig=p1`) + expect(resized.headers.get('content-signature')).toMatch(/^key-id=KEY:RSA:p1\.rec\.net; /) + expect(jpegSize(new Uint8Array(await resized.arrayBuffer())).width).toBe(512) + }) + it('resizes a static asset to ?width, preserving aspect ratio', async () => { const full = new Uint8Array(await (await SELF.fetch(`${ORIGIN}/RecCenter.jpg`)).arrayBuffer()) const original = jpegSize(full) diff --git a/apps/img/wrangler.jsonc b/apps/img/wrangler.jsonc index 8d12589..2e76a11 100644 --- a/apps/img/wrangler.jsonc +++ b/apps/img/wrangler.jsonc @@ -54,11 +54,13 @@ "vars": { "ENVIRONMENT": "development", // overridden during deployment "SENTRY_RELEASE": "unknown", // overridden during deployment - // Feature flag for `?sig=p1` response signing. OFF: signing buffers the whole - // object into the isolate instead of streaming it from R2, then pays a SHA-1 - // over the full body plus an RSA-2048 private-key op on every edge-cache miss. - // Nothing verifies the `Content-Signature` header today. Flip to true (and set - // a real IMG_SIGNING_KEY) if something ever needs to. + // Feature flag for REAL `?sig=p1` signing. OFF: the client only needs a + // Content-Signature header to EXIST, and never checks it, while signing for + // real buffers the whole object into the isolate instead of streaming it from + // R2 and pays a SHA-1 over the full body plus an RSA-2048 private-key op on + // every edge-cache miss. So the header is filled with a placeholder derived + // from the object key (see stubSignature in src/img.app.ts). Flip to true if + // anything ever needs to verify it. "IMG_SIGNING_ENABLED": false, // RSA-2048 private key (PKCS8 DER, base64) used to sign image responses // requested with ?sig=p1. This is an INSECURE DEV KEY committed for local