Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Munin

Munin is a lightweight Cloudflare Worker that acts as a secure, authenticated relay between an application's auto-updater and a private GitHub repository's releases. It lets you ship OTA (over-the-air) update checks and binary downloads to end-user machines without ever putting your GitHub token, or any other secret, on the client.

It is a JavaScript/Cloudflare Workers implementation, designed to be wire-compatible with an earlier Go-based munin-server: the encryption format and HMAC signing scheme are identical, so an existing Python (or other) updater client, and existing encrypted secrets, work against this Worker unmodified.


1. What problem it solves

If you distribute a desktop/CLI app and host binaries as GitHub Releases on a private repo, your auto-updater normally faces a dilemma:

  • Ship a GitHub PAT (Personal Access Token) inside the client so it can call the GitHub API → the token is now on every user's machine.
  • Make the repo public → source and binaries are now public.

Munin sits in between. The client talks only to your Worker, over a signed HTTPS request. The Worker holds the GitHub PAT (encrypted, server-side only) and does the GitHub API calls on the client's behalf, streaming the binary back through itself. The client never sees the PAT, and the request itself can't be tampered with or replayed thanks to HMAC signing.

Because per-app config lives in Workers KV rather than in code, adding a new app ("topic") is a KV write, not a redeploy.


2. Architecture at a glance

flowchart LR
    Client["Client / Updater\n(sends HMAC-signed\nGET request)"]

    subgraph Worker["Munin Worker"]
        direction TB
        Index["index.js\n(routing, decodes\nMUNIN_MASTER_KEY secret\nonce per request)"]
        Middleware["middleware.js\n(verify HMAC signature,\ntimestamp window,\nnonce replay check)"]
        Config["config.js\n(load + decrypt\ntopic config,\nin-memory cache)"]
        Github["github.js\n(resolve latest release,\nverify checksum,\nstream asset bytes)"]

        Index --> Middleware
        Middleware --> Config
        Middleware --> Nonces[("Workers KV\nMUNIN_NONCES\n(seen-nonce set,\nTTL 180s)")]
        Config --> KVConfig[("Workers KV\nMUNIN_CONFIG\n(encrypted PAT +\nencrypted HMAC secret\nper topic)")]
        Index --> Github
    end

    GH[["GitHub REST API\n(private repo releases)"]]

    Client -->|"GET /v1/update\nGET /v1/download"| Index
    Github -->|"Bearer: topic PAT"| GH
    Github -.->|"proxied bytes /\nJSON metadata\n(never the PAT)"| Client

    classDef kv fill:#fef3c7,stroke:#b45309,color:#78350f;
    classDef ext fill:#e0e7ff,stroke:#4338ca,color:#312e81;
    class Nonces,KVConfig kv;
    class GH ext;
Loading

Master key (MUNIN_MASTER_KEY) is a Worker Secret — decoded once per request in index.js, used in-memory to decrypt that topic's PAT/HMAC secret, and never persisted or logged.

Module map

File Responsibility
src/index.js Worker entry point; routing, top-level error handling, master-key decode
src/middleware.js HMAC request verification, timestamp/replay checks
src/config.js Loads + decrypts per-topic config from KV, in-memory decrypt cache
src/crypto.js AES-256-GCM encrypt/decrypt, HMAC-SHA256 sign/verify, SHA-256 hashing
src/github.js GitHub REST API calls: resolve latest release, checksum lookup, asset streaming
tools/encrypt-secret.mjs Local Node CLI for generating the master key and publishing topic config to KV
wrangler.jsonc Cloudflare Workers deployment config (KV bindings, entry point)

3. Request flow

3.1 Update check — GET /v1/update?topic=<name>

  1. Client builds a signed request (see §4) for this path + query.
  2. middleware.js verifies the signature, timestamp window, and nonce; loads and decrypts the topic's config.
  3. github.js calls GET /repos/{repo}/releases/latest with the topic's PAT, finds the asset matching asset_pattern, and finds the checksum asset matching checksum_asset.
  4. It downloads the checksum manifest (expects standard sha256sum output: <hex> <filename> per line) and extracts the hash for the target asset.
  5. Worker responds with JSON:
{
  "version": "v1.4.2",
  "asset_name": "myapp-windows.exe",
  "asset_size": 48213912,
  "sha256": "9f3c...",
  "download_path": "/v1/download?topic=myapp&asset=myapp-windows.exe"
}

Note that no download URL to GitHub is ever returned — only a path back into this same Worker.

3.2 Download — GET /v1/download?topic=<name>&asset=<name>

  1. Same HMAC verification as above.
  2. The asset query parameter must exactly equal the topic's configured asset_pattern; anything else gets 403 asset not permitted for this topic. This means a valid signature for one topic can never be used to pull an unrelated file out of the repo.
  3. findAssetByName re-fetches the current latest release from GitHub and looks up the asset by name again (rather than trusting any client-supplied ID from the earlier /v1/update call). This closes a TOCTOU gap: if a new release lands between the update check and the download, the client gets the current asset, not a stale one.
  4. streamAsset fetches the asset from GitHub with Accept: application/octet-stream and the response body is piped straight through to the client — the Worker never buffers the whole file in memory, so it scales to large binaries within Workers' execution limits.
  5. Response headers: Content-Type: application/octet-stream, Content-Disposition: attachment; filename="<asset>", and Content-Length if GitHub provided one.

3.3 Health check — GET|* /healthz

Returns 200 ok, unauthenticated, for uptime probes. This check happens before the HTTP-method check in index.js, so any method to /healthz gets the same response.


4. Authentication & signing scheme

Every /v1/* request must carry three headers:

Header Meaning
X-Munin-Timestamp Unix seconds when the request was signed
X-Munin-Nonce Random string, 8–128 chars, [a-zA-Z0-9_-]
X-Munin-Signature Hex HMAC-SHA256 signature (see below)

Canonical string to sign:

METHOD + "\n" + PATH_AND_QUERY + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + sha256_hex(BODY)

Signature:

signature = hex( HMAC_SHA256(topic_hmac_secret, canonical_string) )

topic_hmac_secret is the per-topic shared secret established when the topic was created (see §7). The client and the Worker are the only two parties who know it.

Verification order in middleware.js, each step short-circuiting on failure:

  1. topic query param present → else 400 missing topic
  2. Topic exists in KV → else 401 unauthorized (deliberately identical to a bad-signature response, so a caller can't probe which topic names exist)
  3. All three auth headers present → else 401 missing auth headers
  4. Timestamp is a finite number → else 400 invalid timestamp
  5. |now - timestamp| ≤ 120s → else 401 timestamp outside allowed window
  6. Nonce matches the format regex → else 400 invalid nonce format
  7. HMAC signature verifies (via crypto.subtle.verify, constant-time) → else 401 invalid signature
  8. Nonce not seen before for this topic → else 401 replayed request; the nonce is then stored in KV with a 180s TTL (replay window + 60s buffer)

The replay check runs after signature verification specifically so an attacker can't use nonce collisions to fish for which signatures/topics are valid.

Known trade-off: replay protection uses Workers KV, which is eventually consistent across Cloudflare's edge (writes can take up to ~60s to fully propagate). Two requests with the same nonce landing at different points-of-presence within that window could theoretically both slip through. For low-volume, low-value OTA-update traffic this is treated as an acceptable trade-off for a lightweight deployment. middleware.js includes a sketch of a Durable Object–based replacement for anyone who needs a hard consistency guarantee (one strongly-consistent instance per topic to check nonces against).


5. Secrets & encryption model

  • MUNIN_MASTER_KEY — a 32-byte AES-256 key, base64-encoded, stored only as a Cloudflare Worker Secret (never in KV, never in code, never in wrangler.jsonc vars). It's decoded once per incoming request in index.js and attached to env for that request only; it is never logged and disappears when the request completes.
  • Per-topic PAT and HMAC secret are stored in KV already encrypted with the master key, using AES-256-GCM. Ciphertext format is base64(nonce[12 bytes] || ciphertext || tag[16 bytes]) — identical to Go's crypto/cipher AEAD.Seal(nonce, nonce, plaintext, nil) output, so secrets encrypted by the original Go admin tool decrypt correctly here, and vice versa.
  • Decrypted secrets are cached in a module-level, in-memory Map (config.js) for the lifetime of the current Worker isolate (typically minutes), so a warm isolate doesn't re-run AES-GCM on every request. This cache is never written to KV or disk, and evaporates when the isolate recycles.
    • Operational note: because of this cache, if you update a topic's config in KV, warm isolates already holding the old decrypted values will keep using them until they recycle — there's no active invalidation.
  • The GitHub PAT and topic HMAC secret are never returned to any client/v1/update only returns version/name/size/checksum/path metadata, and /v1/download proxies bytes rather than redirecting to a signed GitHub URL, so the token never leaves the Worker.

6. KV data model

Namespace binding: MUNIN_CONFIG (bound in wrangler.jsonc) Key: topic:<name> Value (JSON):

{
  "repo": "owner/private-repo",
  "asset_pattern": "myapp-windows.exe",
  "checksum_asset": "SHA256SUMS.txt",
  "encrypted_pat": "base64(nonce||ciphertext)",
  "encrypted_hmac_secret": "base64(nonce||ciphertext)"
}
Field Meaning
repo owner/name of the private GitHub repo holding releases
asset_pattern Exact filename of the binary this topic serves (also the only filename /v1/download will accept)
checksum_asset Filename of the sha256sum-style manifest asset in the same release
encrypted_pat GitHub PAT, AES-256-GCM–encrypted with the master key
encrypted_hmac_secret Shared HMAC secret for this topic's client, AES-256-GCM–encrypted with the master key

All five fields are required; a missing field throws at request time (topic "<name>" config missing field "<field>").

A second namespace, MUNIN_NONCES, is used purely as a TTL'd "seen nonce" set for replay protection (§4) — it stores no application data.


7. Deployment

Prerequisites

  • Node.js + wrangler (Cloudflare's CLI), npx wrangler works without a global install
  • A Cloudflare account and (for production) a Cloudflare Workers plan that supports KV
  • A GitHub PAT with read access to the private repo(s) you'll serve releases from

One-time setup

# 1. Create the two KV namespaces this Worker needs
npx wrangler kv namespace create MUNIN_CONFIG
npx wrangler kv namespace create MUNIN_NONCES
# → copy the returned ids into wrangler.jsonc's kv_namespaces block
#   (replace the placeholder ids that ship in this repo)

# 2. Generate a master key
node tools/encrypt-secret.mjs genkey
# → prints a base64 32-byte key

# 3. Store it as a Worker Secret (NOT a var — vars are stored in plaintext)
npx wrangler secret put MUNIN_MASTER_KEY
# paste the key from step 2

# 4. Deploy
npx wrangler deploy

Adding a new app ("topic")

No redeploy needed — this is purely a KV write:

MUNIN_MASTER_KEY=<base64-key-from-step-2> node tools/encrypt-secret.mjs put-topic myapp \
  --repo owner/private-repo \
  --asset myapp-windows.exe \
  --checksum-asset SHA256SUMS.txt \
  --pat ghp_xxxxxxxxxxxxxxxx \
  --hmac-secret "a long random shared secret" \
  --namespace-id <MUNIN_CONFIG namespace id>

This encrypts both the PAT and the HMAC secret with your master key, writes the full topic JSON to a temp file (to avoid shell-quoting issues, especially on Windows), and shells out to wrangler kv key put --path <tmpfile> to publish it — then deletes the temp file. It targets your remote (deployed) namespace by default; pass --local to target wrangler dev's local KV simulation instead.

Give the same --hmac-secret value to whatever builds the client's signed requests — it's the shared secret both sides use for HMAC signing (§4).

Other admin-tool commands

node tools/encrypt-secret.mjs genkey
# prints a new base64 32-byte master key

MUNIN_MASTER_KEY=<key> node tools/encrypt-secret.mjs encrypt "some-secret-value"
# prints ciphertext you can paste manually into a topic's
# encrypted_pat / encrypted_hmac_secret field

Sanity-check a deployment

curl https://your-worker.workers.dev/healthz
# -> ok

(/v1/update and /v1/download require a correctly HMAC-signed request — see §4 for the canonical string and signature your client needs to produce.)


8. API reference

Route Auth Description
GET /healthz none Liveness probe. Always 200 ok.
GET /v1/update?topic=<name> HMAC Returns latest release metadata + expected SHA-256 for the topic's configured asset.
GET /v1/download?topic=<name>&asset=<name> HMAC Streams the asset's bytes. asset must equal the topic's asset_pattern exactly.

Error responses (non-exhaustive, all plain-text bodies):

Status Meaning
400 Missing topic, invalid timestamp, or invalid nonce format
401 Unknown topic, missing/invalid auth headers, bad signature, timestamp outside the 120s window, or replayed nonce
403 Requested asset doesn't match the topic's configured asset
404 Unknown path
405 Non-GET method (other than /healthz)
500 Server misconfiguration (missing/invalid MUNIN_MASTER_KEY)
502 Upstream GitHub call failed (bad PAT, release/asset not found, GitHub outage, etc.)

9. Security summary

  • GitHub PAT and per-topic HMAC secret are never exposed to any client, and never stored in plaintext anywhere — only AES-256-GCM ciphertext in KV, decrypted transiently per-isolate using a Worker Secret that's never logged.
  • Requests are HMAC-signed over method, path+query, timestamp, nonce, and a hash of the body, so signed requests can't be replayed verbatim or have their target path/query silently swapped.
  • A 120-second timestamp window plus a KV-backed nonce cache bound how long a captured request stays valid.
  • The download endpoint enforces a strict allow-list (the asset filename configured for that topic) and re-resolves the asset against the current latest release rather than any client- or previously-fetched identifier, closing a TOCTOU gap between checking for an update and downloading it.
  • Unknown-topic and bad-signature responses are indistinguishable (both 401 unauthorized), so the API doesn't leak which topic names are valid.

10. Known limitations

  • Nonce replay protection is eventually consistent, not strongly consistent, because it's backed by Workers KV (§4). A Durable Object–based alternative is sketched in middleware.js for anyone who needs a hard guarantee.
  • No active cache invalidation: updating a topic's KV entry doesn't affect isolates that already have that topic's secrets decrypted and cached in memory; they'll pick up the change only once they recycle.
  • Single "latest release" model: there's no built-in concept of pinned versions, staged rollouts, or percentage-based channels — every client for a topic always gets whatever GitHub currently reports as the latest release.
  • Malformed topic JSON in KV (missing fields, invalid JSON) throws inside getTopicConfig; since that call isn't wrapped in its own try/catch in index.js, a broken topic entry currently surfaces as a generic unhandled-Worker-exception response rather than a clean, topic-specific error message.

About

Munin is a lightweight Cloudflare Worker that acts as a secure, authenticated relay between an application's auto-updater and a private GitHub repository's releases.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages