khalidadil
  • Joined on 2025-08-25

@khalidadil/filebot-signing (0.1.0)

Published 2026-09-18 15:42:16 +00:00 by khalidadil

Installation

@khalidadil:registry=
npm install @khalidadil/filebot-signing@0.1.0
"@khalidadil/filebot-signing": "0.1.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 WebCrypto crypto.subtle.
  • Source never imports node:*, never touches Buffer or process, never logs, and never generates random values at module scope (workerd forbids it). randomBytes, generateRoot, newJti and generateWebhookSecret draw randomness only when called.
  • Every function is pure and synchronous except the WebCrypto sealing calls (Promise) and verifyWebhook's default Date.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:

  1. doc present and doc.pid matches → else E_DOC; bucket exists → else E_BUCKET
  2. /p/: bucket public and rendition not x//r → else E_SCOPE; any dl → E_DL
  3. /s/: dl syntax → E_DL
  4. key + signature (always one HMAC, DUMMY_KEY for unknown or unusable kids) → E_KID (with unknownKid: true when the kid is absent from the doc, so the edge may refetch once) or E_SIG
  5. now > exp + 60 → E_EXP; exp > now + cap + 3600 + 60 → E_TTL
  6. scope rules and dl semantics (dl only with scope o and rendition o/r) → E_SCOPE / E_DL
  7. checkRenditionPolicy(bucket, r) → E_POLICY (preset exists, width in ladder, x/ allowed and within maxW/maxH, fmt in fmts ∪ {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 a sign block (message, sig, URL) and/or a raw target, optional mutate ops, and an authorize verdict (ok + storageSuffix, or a DenyReason).
  • jwt: list/upload tokens with verdicts (ok or a reject reason).
  • gen / webhook: headers and accept/reject verdicts around the time windows and rotation.
  • doc: seal envelopes (fixed IVs), unseal verdicts with SealError codes, 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 without nodejs_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
Details
npm
2026-09-18 15:42:16 +00:00
0
104 KiB
Assets (1)
Versions (2) View all
0.2.0 2026-09-26
0.1.0 2026-09-18