@khalidadil/filebot-signing (0.2.0)
Installation
@khalidadil:registry=npm install @khalidadil/filebot-signing@0.2.0"@khalidadil/filebot-signing": "0.2.0"About this package
@khalidadil/filebot-signing
The one implementation of every Filebot wire format that involves a secret or a canonical string: signed serve URLs and their edge authorization, list and upload JWTs, the generator header, edge project-doc sealing and MAC, webhook signatures, storage-key mapping and the imgproxy render spec. The Filebot API, workers, generator, Node edge, Cloudflare Worker edge and the server SDK all import this package, so a URL signed anywhere verifies everywhere, byte for byte.
pnpm add @khalidadil/filebot-signing # registry: https://git.kadil.dev/api/packages/khalidadil/npm/
Runtimes and rules
- Node ≥ 20.19 and Cloudflare Workers (workerd, no
nodejs_compat) run the same code. Browsers import only@khalidadil/filebot-signing/public. - Pure ESM,
sideEffects: false. One runtime dependency:@noble/hashes@2.4.0(sync HMAC, SHA-256, HKDF). AES-GCM uses the global WebCryptocrypto.subtle. - Source never imports
node:*, never touchesBufferorprocess, never logs, and never generates random values at module scope (workerd forbids it).randomBytes,generateRoot,newJtiandgenerateWebhookSecretdraw randomness only when called. - Every function is pure and synchronous except the WebCrypto sealing calls (
Promise) andverifyWebhook's defaultDate.now(). Error messages name the field, never a secret value. - Verifiers return reason codes; callers log the reason and answer with one indistinguishable response
(edge:
404 Not Found; API:401).
Entry points
| Import | Contents |
|---|---|
@khalidadil/filebot-signing |
everything below |
@khalidadil/filebot-signing/public |
secret-free subset: grammar (UUID_RE, KID_RE, EXP_RE, SIG_RE, PRESET_RE, HLS_EXTS, HLS_MIME, isUuid, isKid, parseRendition, renditionString, formatRef, parseRef), parseOpts, canonOpts, canonDl, parseDlQuery, bucketExp, DEFAULT_TTL, hlsTtlSec, buildSrcset, ENGINE_REV, QUALITY_DEFAULTS, legacyFormat, INLINE_SAFE_TYPES, types |
@khalidadil/filebot-signing/test-vectors/<kind>-v1.json |
committed vectors (url, jwt, gen, webhook, doc) |
./public reaches no HMAC, key, JWT, seal, webhook, generator, imgproxy, authorize or
checkRenditionPolicy code; a purity test enforces it.
Exports by area
| Area | Exports |
|---|---|
| Bytes | utf8, fromUtf8, toHex, fromHex, b64url, fromB64url (canonical only), timingSafeEqual, randomBytes |
| Hashes | hmacSha256, sha256Bytes, sha256Hex |
| Keys | deriveKey, DUMMY_KEY, generateRoot, encodeRoot, decodeRoot, selectSigningKey, decodeKey32, purposeKey |
| Grammar | regexes, parseRendition, renditionString, formatRef, parseRef, parseOpts, canonOpts, canonDl, parseDlQuery, bucketExp, DEFAULT_TTL, hlsTtlSec, canonicalJson |
| URL signing | urlTarget, canonicalMessage, signUrl, normalizeServeBase, buildSignedUrl, buildPublicUrl, buildSignedBase, buildSrcset |
| Edge authorization | parseServePath, authorize, checkRenditionPolicy, acceptsType, resolveFmt, resolveRendition |
| Storage keys | storageSuffix, storageKey, storageKeys |
| Render spec | ENGINE_REV, QUALITY_DEFAULTS, legacyFormat, renderSpec, displaySpec, imgproxyOptions, IMGPROXY_OUTPUT_ENV, INLINE_SAFE_TYPES, imgproxySourcePath, signImgproxyPath |
| Edge doc | parseProjectDoc, parseBucketDoc, docMac, verifyDocMac, SealError, parseKek, edgeAadUrlKey, edgeAadCred, sealEdgeSecret, unsealEdgeSecret, encodeEdgeCred, decodeEdgeCred |
| Generator | genPath, parseGenPath, genSecretBytes, genHeader, verifyGenHeader |
| JWTs | peekJwt, jwtKind, LIST_JWT_TYP, UPLOAD_JWT_TYP, signListToken, verifyListToken, validateListClaims, signUploadToken, verifyUploadToken, validateUploadClaims, uploadTokenHash, uploadPolicyHash, newJti |
| Webhooks | generateWebhookSecret, webhookSignatureHeader, parseWebhookSignature, verifyWebhook |
Contract types (UrlClaims, ParsedServe, AuthResult, DenyReason, ProjectDoc, BucketDoc,
ListClaims, UploadClaims, RenderSpec, …) are exported from both entries.
Keys
root (32 random bytes per project + kid, shown to the host once as 43-char base64url)
deriveKey(root, p) = HMAC-SHA256(root, utf8("filebot/v1/" + p)) p ∈ url | list | upload
INTERNAL_HMAC_KEY (32 B, box only)
purposeKey(master, p) = HKDF-SHA256(master, salt = empty, info = utf8("filebot/" + p + "/v1"), 32)
p ∈ upload-key | sse-ticket
selectSigningKey(keys, now) picks the active key with the newest notBefore ≤ now (ties: greatest
kid). decodeKey32(raw, NAME) accepts 64 hex chars or (url-safe) base64 of exactly 32 bytes.
Signed URLs
signed : {serveBase}/s/{kid}~{exp}~{scope}~{sig}/{pid}/{bid}/{oid}/{rendition}[?dl={dl}]
public : {serveBase}/p/{pid}/{bid}/{oid}/{rendition}
message = "FBURL1\n{kid}\n{exp}\n{scope}\n{pid}\n{bid}\n{target}\n{dl}" (UTF-8, LF-joined, no trailing LF)
target : o → "{oid}/{rendition}" · f → "{oid}/" · h → "{oid}/hls/" · b → ""
sig = base64url_nopad(HMAC-SHA256(deriveKey(root, "url"), message)) (43 chars)
exp = bucketExp(now, ttl) = ceil((now + ttl) / B) * B, B = ttl ≥ 3600 ? 3600 : ttl ≥ 300 ? 300 : 60
| Scope | Covers | Max TTL at the edge |
|---|---|---|
o |
exactly one {oid}/{rendition} (+ dl); the only scope for r and x/ |
bucket maxTtl |
f |
every rendition of one oid except x/ and r |
bucket maxTtl |
h |
{oid}/hls/** |
bucket maxHlsTtl |
b |
any oid in the bucket except x/ and r; not on private buckets |
min(maxTtl, 7200) |
Renditions: o (servable original) · r (raw untouched bytes, attachment only) ·
p/{preset}.{fmt} · w/{width}.{fmt} · x/{opts}.{fmt} · hls/[dir/]{0,2}file, with
fmt ∈ avif | webp | legacy | auto. Transform opts are _-joined in the fixed order
w{n} h{n} fit-{cover|contain|fill} g-{no|so|ea|we|sm} q{1..100} dpr{2|3}, defaults omitted; fit
needs both w and h, gravity needs fit-cover, and only canonOpts(parseOpts(s)) === s is valid.
dl is canonDl(filename): NFC, strip C0, DEL, C1 and / \ " ;, cap at 200 UTF-8 bytes on a code
point boundary, uppercase %XX for every byte outside [A-Za-z0-9._-].
Identical inputs inside one exp bucket give byte-identical URLs (no clock, no randomness).
Authorization (edge)
parseServePath(pathname, search) reads the raw path (no decoding; %, //, dot segments, uppercase
outside sig and trailing / never parse). authorize(parsed, doc, keyLookup, now) then checks, first
failure wins:
- doc present and
doc.pidmatches → elseE_DOC; bucket exists → elseE_BUCKET /p/: bucket public and rendition notx//r→ elseE_SCOPE; anydl→E_DL/s/:dlsyntax →E_DL- key + signature (always one HMAC,
DUMMY_KEYfor unknown or unusable kids) →E_KID(withunknownKid: truewhen the kid is absent from the doc, so the edge may refetch once) orE_SIG now > exp + 60→E_EXP;exp > now + cap + 3600 + 60→E_TTL- scope rules and
dlsemantics (dlonly with scopeoand renditiono/r) →E_SCOPE/E_DL checkRenditionPolicy(bucket, r)→E_POLICY(preset exists, width in ladder,x/allowed and withinmaxW/maxH, fmt infmts ∪ {legacy, auto}) — the generator calls the same predicate
E_ROUTE (parse failure) and E_MISSING (origin 404) are emitted by the edge around authorize.
resolveRendition(r, accept, fmts) turns auto into avif, then webp, then legacy, honouring
both the Accept header (exact types, q=0 excluded) and the bucket's fmts.
Storage keys
storageKey(bucket, origin, oid, resolved) = {origin.root}/ + storageSuffix(...):
o/{oid} · r/{oid} · v/{oid}/r{presetRev}/p/{preset}.{fmt} · v/{oid}/r{presetRev}/w/{n}.{fmt} ·
v/{oid}/e{engineRev}/x/{opts}.{fmt} · v/{oid}/hls/{rest}. Nothing this package builds can reach
_q/, _hold/, _t/, .. or //. storageKeys returns one key per origin, in doc order.
Render spec and inline-safe types
renderSpec(resolved, presets, sourceMime) and displaySpec(sourceMime) describe every imgproxy
render; imgproxyOptions(spec) gives the option string (w/640.webp → rs:fit:640:0:0/q:78/sm:1);
signImgproxyPath(keyHex, saltHex, path) signs it. Pregeneration and on-demand generation share these,
and both imgproxy services must set IMGPROXY_OUTPUT_ENV verbatim, so their bytes match.
ENGINE_REV (ip3-1) must be bumped whenever an option string or the pinned imgproxy changes; a
fingerprint test fails the build otherwise, and x/ keys move to the new e{engineRev} prefix.
INLINE_SAFE_TYPES is the one list of sniffed MIME types that may be stored under their own
Content-Type: image/jpeg, image/png, image/webp, image/avif, image/gif, video/mp4,
video/webm, audio/mpeg, audio/mp4, audio/ogg, application/pdf. Everything else,
including SVG, is stored as application/octet-stream. Serve-time disposition (PDF inline only with
inlinePdf) is the edge's job.
JWTs
| List | Upload | |
|---|---|---|
typ |
fb-list+jwt |
fb-upload+jwt |
| key | deriveKey(root, "list") |
deriveKey(root, "upload") |
aud |
filebot:list |
filebot:upload |
| max TTL / leeway | 3600 s / 60 s | min(maxTtlSec, 3600) / 30 s |
| claims | iss aud sub? bkt idf act iat exp |
iss aud jti iat exp bkt ct max n? tot? su? idf? meta? sid? |
Header bytes are exactly {"alg":"HS256","typ":…,"kid":…}; claims are serialized in the order above.
Verification order: malformed → bad_header → unknown_kid → bad_signature → bad_iss
(fb:{pid}) → bad_aud → bad_claims (unknown claims or limits) → ttl_too_long → expired →
not_yet_valid. Look the key up with peekJwt(token) (unverified typ, kid, pid) or
jwtKind(token), then verify. uploadTokenHash(iss, jti) = sha256hex("fb-upload:{iss}:{jti}");
uploadPolicyHash(claims) = sha256hex(canonicalJson(claims)).
Generator header
GET /gen/v1/{pid}/{bid}/{oid}/{resolvedRendition} with X-Fb-Gen: {exp}.{sig},
exp = now + 60, sig = b64url(HMAC-SHA256(utf8(GEN_SECRET), "FBGEN1\n{exp}\nGET\n{path}")).
verifyGenHeader accepts |exp - now| ≤ 120 and tries every secret (GEN_SECRET, GEN_SECRET_PREV).
GEN_SECRET must be at least 32 UTF-8 bytes.
Edge project doc
Secrets in the doc are sealed with AES-256-GCM under EDGE_KEK (never CREDENTIALS_ENCRYPTION_KEY):
envelope = "e1:{kekId}:{b64url 12-byte iv}:{b64url ciphertext‖tag}"
AAD = "fb-edge|{pid}|url|{kid}" (plaintext: the 32-byte url key, never the root)
| "fb-edge|{pid}|cred|{bid}|{loc}" (plaintext: canonicalJson({accessKeyId, secretAccessKey}))
mac = "e1m:{kekId}:" + b64url(HMAC-SHA256(HKDF-SHA256(kek, empty, "fb-edge-doc/v1", 32),
canonicalJson(doc without "mac")))
unsealEdgeSecret throws SealError with code malformed, unknown_kek or auth_failed; pass both
EDGE_KEK and EDGE_KEK_PREV during rotation. parseProjectDoc validates the shape (unknown fields
ignored); verifyDocMac(doc, keks) must pass before the edge uses a doc.
Webhook signatures
Filebot-Signature: t={unix},v1={hex}[,v1={hex}]
v1 = hex(HMAC-SHA256(utf8(secret), utf8("{t}.") ‖ rawBody)) secret = "whsec_" + b64url(32 random bytes)
During rotation the previous secret co-signs as a second v1 for 24 h. Receivers call
verifyWebhook(rawBody, header, secretOrSecrets, { toleranceSec: 300 }) with the exact bytes received;
any v1 matching any secret passes.
Test vectors (for non-JS hosts)
The package ships test-vectors/{url,jwt,gen,webhook,doc}-v1.json. Each file is
{ format: "filebot-vectors", kind, version: 1, … , cases: [...] } where every case carries its
inputs, the expected verdict (expect) and the exact outputs (out: messages, keys in hex,
signatures, URLs, tokens, headers, envelopes) produced by an independent node:crypto oracle
(scripts/oracle.ts, which imports nothing from src/). An implementation in another language is
conformant when it reproduces every out and every expect:
- url:
derive(key derivation),canon(canonDl,parseOpts,bucketExp), and cases with asignblock (message, sig, URL) and/or a rawtarget, optionalmutateops, and anauthorizeverdict (ok+storageSuffix, or aDenyReason). - jwt: list/upload tokens with verdicts (
okor a reject reason). - gen / webhook: headers and accept/reject verdicts around the time windows and rotation.
- doc: seal envelopes (fixed IVs), unseal verdicts with
SealErrorcodes, doc MACs.
Vectors are regenerated only from the lead-owned *.cases.json files (not published):
pnpm run vectors:write, then pnpm run vectors:check must pass.
Proofs in this repository
pnpm test— unit and property tests per module, the vector runner under Node, crypto parity (noble ≡node:crypto≡ WebCrypto for HMAC, SHA-256, HKDF, AES-GCM) and purity checks.pnpm run test:workerd— bundles the vector runner with esbuild (neutral ESM), runs it in workerd through Miniflare withoutnodejs_compat, and requires the same executed counts as Node with zero failures, plus 500 seeded WebCrypto-vs-noble HMAC cases.pnpm run vectors:check— the oracle regenerates every vector file and byte-compares.
Dependencies
Dependencies
| ID | Version |
|---|---|
| @noble/hashes | 2.4.0 |
Development Dependencies
| ID | Version |
|---|---|
| @types/node | ^22.10.0 |
| esbuild | ^0.25.0 |
| miniflare | ^4 |
| tsx | ^4.19.2 |
| typescript | ^5.7.0 |