Discreet upload / download using a Cloudflare Worker on dud.example.com, R2
for storage, and a Dockerized client that uses curl plus age.
- Encrypts files locally with
age --passphrasebefore upload. - Uploads only ciphertext to the Worker.
- Returns an opaque ID that the recipient can use to fetch ciphertext.
- Decrypts locally after download with the shared passphrase.
- Opportunistically cleans up expired or consumed R2 objects during normal traffic.
- Verifies secure transport from the client with DoH, TLS 1.3, and
curl --ech, usinghardby default.
No web UI is provided by design. Browsers cannot enforce ECH hard mode, DoH, or TLS 1.3 the way the Docker client does — the transport security guarantees that define this tool's threat model require a controlled client stack.
Stack:
These steps assume you want to deploy your own Cloudflare-backed DUD service, but use a prebuilt Docker client image rather than building the client locally.
git clone https://github.com/wojciechpolak/dud.git
cd dudnpm cinpx wrangler loginCreate the R2 bucket:
npx wrangler r2 bucket create dud-filesStart from the checked-in example:
cp wrangler.example.toml wrangler.tomlThen edit wrangler.toml before the first deployment:
- keep
name = "dud"unless you want a different Worker name - change
pattern = "dud.example.com"if you want to use a different hostname - keep
bucket_name = "dud-files"only if that is the bucket you created - keep or adjust
APP_VERSION
The real wrangler.toml is gitignored so machine-specific IDs and future local
changes stay out of the repository.
Important: Wrangler commands may suggest a different binding name such as
dud_files. In this repository, the Worker code expects this exact binding:
- R2 binding:
FILES
So keep this shape in your local wrangler.toml unless you also change the
Worker code:
[[r2_buckets]]
binding = "FILES"
bucket_name = "dud-files"npm run checknpx wrangler deployAfter deploy, make sure dud.example.com is actually routed through Cloudflare
and resolves to the Worker custom domain you configured.
Uploads and the manual flush command both require the same Worker secret:
npx wrangler secret put DUD_SECRET_TOKENThe value of this secret is later passed to the Docker client as
DUD_SECRET_TOKEN when you want to upload files or run flush.
Pick the published image name you want to use and pull it once:
docker pull ghcr.io/wojciechpolak/dud/dud-client:latestsrc/: Worker code and Cloudflare adapters.client/: Docker client image and entrypoint script.tests/: Worker and client tests.
Returns readiness JSON:
{
"ok": true,
"service": "dud",
"host": "dud.example.com",
"version": "1.0.0"
}Uploads an encrypted payload stream.
Request headers:
x-dud-secret-token: must match the WorkerDUD_SECRET_TOKENsecretx-dud-ttl: TTL such as15m,24h,7d. Default24h.x-dud-delete-after-read:trueorfalse. Defaultfalse.content-length: optional but recommended.
Response:
{
"id": "opaque-random-id",
"expiresAt": "2026-04-19T12:00:00.000Z",
"deleteAfterRead": false
}Streams ciphertext back when the file is still available.
404: unknown ID410: expired or already consumed
Deletes expired and already-consumed objects from R2 immediately.
Request headers:
x-dud-secret-token: must match the WorkerDUD_SECRET_TOKENsecret
Response:
{
"ok": true,
"deletedCount": 3
}npm run check
npx wrangler deployPull the published image:
docker pull ghcr.io/wojciechpolak/dud/dud-client:latestDefault environment:
DUD_BASE_URL=https://dud.example.comDUD_DOH_URL=https://cloudflare-dns.com/dns-queryDUD_ECH_MODE=hardDUD_SECRET_TOKENwhen usinguploadorflush
DUD_ECH_MODE accepts:
hard: fail if ECH cannot be usedgrease: send ECH GREASE while allowing fallback behavior
The Dockerfile builds curl from source with ECH enabled using curl's
experimental ECH build path instead of relying on a distro package.
Examples:
docker run --rm -it -v "$PWD:/work" ghcr.io/wojciechpolak/dud/dud-client:latest testThe test command always prints a short summary including the DoH resolver, ECH
mode, negotiated TLS details, ALPN, and the ECH result reported by curl,
followed by the Worker's /v1/test JSON response.
docker run --rm -it --tmpfs /tmp:rw,noexec,nosuid,size=128m -e DUD_SECRET_TOKEN=YOUR_TOKEN -v "$PWD:/work" ghcr.io/wojciechpolak/dud/dud-client:latest upload --file /work/input.bin --ttl 24h
docker run --rm -it --tmpfs /tmp:rw,noexec,nosuid,size=128m -v "$PWD:/work" ghcr.io/wojciechpolak/dud/dud-client:latest download --id YOUR_ID --out /work/output.bin
docker run --rm -it --tmpfs /tmp:rw,noexec,nosuid,size=128m -e DUD_SECRET_TOKEN=YOUR_TOKEN ghcr.io/wojciechpolak/dud/dud-client:latest flushSecurity note:
--tmpfs /tmpkeeps sensitive intermediate files (encrypted payloads, TLS traces) in memory only — they never reach the container's overlay filesystem and are gone when the container exits.
To avoid repeating the full docker run flags, install a thin host wrapper:
# Wrapper script at /usr/local/bin/dud
docker run --rm ghcr.io/wojciechpolak/dud/dud-client:latest install \
| sudo tee /usr/local/bin/dud && sudo chmod +x /usr/local/bin/dudThen: dud test, dud upload ..., etc.
Or as a shell alias (add to ~/.bashrc or ~/.zshrc)
# 1. Review what will be added
docker run --rm ghcr.io/wojciechpolak/dud/dud-client:latest shell-alias
# 2. Append to your shell rc
docker run --rm ghcr.io/wojciechpolak/dud/dud-client:latest shell-alias >> ~/.profileSet DUD_IMAGE to override the image name embedded in the output.
Run this before trusting the endpoint:
docker run --rm -it \
--tmpfs /tmp:rw,noexec,nosuid,size=128m \
-e DUD_BASE_URL=https://dud.example.com \
-v "$PWD:/work" \
ghcr.io/wojciechpolak/dud/dud-client:latest testThis command succeeds only if curl can reach the service with DoH, TLS 1.3, and
--ech "$DUD_ECH_MODE" using hard by default.
If you want to try GREASE mode instead:
docker run --rm -it \
--tmpfs /tmp:rw,noexec,nosuid,size=128m \
-e DUD_BASE_URL=https://dud.example.com \
-e DUD_ECH_MODE=grease \
-v "$PWD:/work" \
ghcr.io/wojciechpolak/dud/dud-client:latest testSuppose the sender wants to share secret.pdf and keep it available for 48
hours:
docker run --rm -it \
--tmpfs /tmp:rw,noexec,nosuid,size=128m \
-e DUD_BASE_URL=https://dud.example.com \
-e DUD_SECRET_TOKEN=YOUR_SECRET_TOKEN \
-v "$PWD:/work" \
ghcr.io/wojciechpolak/dud/dud-client:latest upload --file /work/secret.pdf --ttl 48hThe client will prompt for the passphrase through age. Pick a passphrase and
share it with the recipient out of band.
The upload response will look like this:
{
"id": "3df75d5c0c3b4f53ac1b8eeb23704fbe",
"expiresAt": "2026-04-20T12:00:00.000Z",
"deleteAfterRead": false
}Only two things need to be shared with the recipient:
- the
id - the passphrase
On another machine, the recipient can fetch and decrypt it like this:
docker run --rm -it \
--tmpfs /tmp:rw,noexec,nosuid,size=128m \
-e DUD_BASE_URL=https://dud.example.com \
-v "$PWD:/work" \
ghcr.io/wojciechpolak/dud/dud-client:latest download \
--id 3df75d5c0c3b4f53ac1b8eeb23704fbe \
--out /work/received-secret.pdfThe client downloads ciphertext from the Worker, prompts for the passphrase, and
writes the decrypted file to /work/received-secret.pdf.
You do not run age separately on the host after download. The Docker client
container performs age --decrypt internally and writes the plaintext output to
the path given with --out.
If the sender wants the file to disappear after the first successful download,
add --delete-after-read during upload:
docker run --rm -it \
--tmpfs /tmp:rw,noexec,nosuid,size=128m \
-e DUD_BASE_URL=https://dud.example.com \
-v "$PWD:/work" \
ghcr.io/wojciechpolak/dud/dud-client:latest upload \
--file /work/secret.pdf \
--ttl 24h \
--delete-after-readAfter one successful retrieval, the same id will return 410 Gone.
If you configured the Worker DUD_SECRET_TOKEN secret, you can force a cleanup
pass whenever you want:
docker run --rm -it \
--tmpfs /tmp:rw,noexec,nosuid,size=128m \
-e DUD_BASE_URL=https://dud.example.com \
-e DUD_SECRET_TOKEN=YOUR_SECRET_TOKEN \
ghcr.io/wojciechpolak/dud/dud-client:latest flushThis deletes expired and already-consumed objects from R2 immediately and
returns a JSON response with deletedCount.
- v1 is designed for files up to 100 MB, which keeps the transfer path compatible with common Cloudflare request body limits.
- The Worker is not the trust boundary for ECH. The client verifies secure transport before upload or download.
- Cleanup is cron-free. Expired and consumed objects are removed during normal
traffic, and
flushis available for an explicit cleanup pass.
- Repository default: MIT License unless a more specific component license applies