mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
oops, just return a fake signature
This commit is contained in:
+19
-13
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user