Send someone a secret over a link that works once.
Go to file
jx12n 138ab7306e
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
release: build through the cluster, not through its public address
Four consecutive Kaniko pushes failed with
`dial tcp 208.122.204.172:443: connect: connection refused` while zot and
Traefik were both healthy and my laptop reached the same URL fine.

Measured from a pod in the same namespace, with the same labels the build Job
carries: 14 of 24 requests to https://registry.threesix.ai/v2/ succeeded, and
8 of 8 succeeded against Traefik's ClusterIP with the same SNI. So the flaky
leg is pod -> the cluster's own public address, and the build was taking it
for both the clone and the push.

The Job now carries hostAliases pinning git.threesix.ai and
registry.threesix.ai to Traefik's ClusterIP, read from the Service at release
time. The image is still named registry.threesix.ai/hush/api:SHA — the route
changes, the reference does not, and the kubelet pull path is untouched.

No retry loop: a retry would have made a 40% failure rate invisible instead of
fixed.

docs/DEPLOY.md no longer duplicates the Job YAML. That duplicate is why the
push guard drifted onto the wrong remote, and a second copy of a spec nobody
runs is worth less than a pointer to the one that runs.
2026-09-05 14:28:42 -06:00
cmd serve the MCP install instructions at /mcp 2026-09-05 14:03:34 -06:00
deployments/k8s serve the MCP install instructions at /mcp 2026-09-05 14:03:34 -06:00
docs release: build through the cluster, not through its public address 2026-09-05 14:28:42 -06:00
internal serve the MCP install instructions at /mcp 2026-09-05 14:03:34 -06:00
scripts release: build through the cluster, not through its public address 2026-09-05 14:28:42 -06:00
vendor hush: one-time secret links the server cannot read 2026-09-03 00:08:38 -06:00
.dockerignore document the CSP the pages override; allowlist the build context 2026-09-03 01:01:28 -06:00
.gitignore document the CSP the pages override; allowlist the build context 2026-09-03 01:01:28 -06:00
.woodpecker.yml hush: one-time secret links the server cannot read 2026-09-03 00:08:38 -06:00
Dockerfile hush: one-time secret links the server cannot read 2026-09-03 00:08:38 -06:00
go.mod hush: one-time secret links the server cannot read 2026-09-03 00:08:38 -06:00
go.sum hush: one-time secret links the server cannot read 2026-09-03 00:08:38 -06:00
Makefile serve the MCP install instructions at /mcp 2026-09-05 14:03:34 -06:00
README.md serve the MCP install instructions at /mcp 2026-09-05 14:03:34 -06:00

hush

Send someone a secret over a link that works once.

Production: https://hush.threesix.ai

Paste a secret, get a link, send the link. The first person to open it and press Reveal sees the secret; the link is dead from that moment. Nobody needs an account, a client, or anything installed — a browser is the whole requirement.

The server cannot read what you sent. Encryption happens in your browser and the key lives in the URL fragment (…/s/ID#KEY), which browsers never transmit. hush stores ciphertext it has no way to open. That is not a promise about our operational discipline; it is a property of where the key sits.

What one-time actually buys you

Worth being precise, because "one-time link" is often oversold:

  • Bounded exposure. The secret is fetchable once, for at most its TTL, then it is gone. A credential sitting in a Slack thread is fetchable forever by anyone who later gains access to that thread.
  • Tamper evidence. If your recipient says "already used", someone else opened it. You have learned something a plain paste never tells you.
  • Nothing at rest to steal. A dump of hush's Redis yields ciphertext and no keys.

And what it does not buy you:

  • It does not protect the link. Whatever channel carries the link could be read by whoever can read that channel. One-time-ness limits the damage and makes it detectable; it does not make the channel private.
  • It does not authenticate the reader. Anyone holding the link can open it. The link is the capability. Treat it like the secret it carries.

If a secret must reach one specific verified human and nobody else, this is the wrong tool — use a channel with identity.

Usage

In a browser

  1. Open https://hush.threesix.ai.
  2. Paste the secret, press create a secret.
  3. Copy the link and send it however you like.
  4. The recipient opens it, presses reveal the secret, and reads it once.

There is no lifetime picker: the page offers one action, and the server applies its default TTL (24 hours). ttl_seconds on the API is where a caller that cares chooses.

Why there is a button

Slack, Teams, WhatsApp, iMessage and Outlook Safe Links all fetch a URL to build a preview before any human sees it. A service that destroys on GET therefore destroys most secrets in transit, and the recipient's "already used" is indistinguishable from a real interception.

So in hush, GET /s/{id} is a static page that touches no storage at all. Only POST /s/{id}/reveal reads and destroys. Link previewers are harmless by construction, not by user-agent guessing.

API

The API takes ciphertext. There is no endpoint that accepts a plaintext secret, because such an endpoint would make the server able to read secrets and the claim at the top of this file would become a matter of trust rather than arithmetic.

POST /api/secrets
{ "ciphertext": "<base64url AES-256-GCM, nonce prepended>", "ttl_seconds": 86400 }
→ 201 { "id": "…", "expires_at": "2026-09-04T…Z", "ttl_seconds": 86400 }

POST /api/secrets/{id}/reveal
→ 200 { "ciphertext": "…" }        first caller only, secret destroyed
→ 410 { "error": { "code": "gone" } }   every other case

410 gone is returned identically whether the id never existed, was already revealed, or expired. Distinguishing those would confirm to an attacker that a particular link once existed.

GET / serves the create page, GET /s/{id} the reveal page. /healthz, /readyz and /metrics are served on the same port but are not routed by the public ingress — they are reachable in-cluster only.

From an agent, over MCP

cmd/hush-mcp is a stdio MCP server exposing two tools, hush_create and hush_reveal. It runs locally and does the encryption on your machine, so using hush from an agent preserves the same zero-knowledge property as using it from a browser.

go install github.com/orchard9/hush/cmd/hush-mcp@latest

Per-client configuration — Claude Code, Codex, Gemini, VS Code, Claude Desktop, Cursor, omp — is served by the deployment at https://hush.threesix.ai/mcp. docs/MCP.md covers the design.

Limits

Thing Value Why
Ciphertext ≤ 64 KiB It is a courier for credentials, not a file host
TTL 5m … 7d, default 24h Long enough to be useful, short enough to bound exposure
Rate limit 30 creates / 10 min / IP Anonymous create is otherwise a free blob host
Reveals per secret exactly 1 The product

Operating it

Development

make help          # every target
make release       # build this commit in-cluster and roll it out, then smoke it
make test          # unit tests, no external dependencies
make dev           # a local Redis in Docker + hushd on :18500
make smoke         # full create → reveal → gone against the local instance
make vendor        # refresh vendor/ after a dependency change

go-chassis is a private module, so dependencies are vendored and both CI and the container build run with -mod=vendor and no network. make vendor is the only way dependency versions change.