oops, just return a fake signature

This commit is contained in:
Devin Zuczek
2026-08-07 15:17:53 -04:00
parent 4e578f7771
commit c2d36009a3
5 changed files with 187 additions and 68 deletions
+19 -13
View File
@@ -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 /<key>?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=<base64>`
header. The client uses this to verify image integrity. **Off by default**
see below.
- `GET /<key>?sig=p1` — same, plus a
`Content-Signature: key-id=KEY:RSA:p1.rec.net; data=<base64>` 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
+5 -4
View File
@@ -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
}
+107 -32
View File
@@ -152,17 +152,88 @@ async function signImage(env: Env, bytes: BufferSource): Promise<string | null>
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<Response> {
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<Response> {
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=<base64>`. 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=<base64>`',
'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 })
+49 -14
View File
@@ -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<Response> {
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)
+7 -5
View File
@@ -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