hush/README.md
jx12n 4d9a26498e hush: one-time secret links the server cannot read
Paste a secret, get a link, send it. The first person to open it and press
Reveal sees the secret; the link dies at that moment. The recipient needs a
browser and nothing else — no account, no client, no installed tooling.

The server cannot read what it stores. AES-256-GCM happens in the browser and
the key lives in the URL fragment, which browsers never transmit, so hushd
holds ciphertext and no key material. That is a property of where the key sits
rather than a promise about our conduct, which is why there is deliberately no
endpoint accepting a plaintext secret and no server-side-encryption fallback:
two guarantees behind one URL would be worse than one honest guarantee.

Three decisions carry the design:

  * GET /s/{id} touches NO storage, not even to check existence. Slack, Teams,
    WhatsApp, iMessage and Outlook Safe Links all fetch a URL before a human
    sees it, so destroying on GET would destroy most secrets in transit and the
    recipient's "already used" would be indistinguishable from interception.
    Only POST /reveal consumes. Bot user-agent detection is an arms race;
    removing the side effect from GET is not. Pinned by
    TestGettingTheRevealPageNeverConsumesTheSecret.
  * Destruction is one Redis GETDEL, which is atomic. GET-then-DEL has a window
    where two simultaneous readers both win, and for a one-time secret that
    window is the product. The store contract demands atomicity and the same
    concurrency test runs against both implementations.
  * Missing, already-revealed, expired and evicted are ONE indistinguishable
    410. Separating them would confirm to a prober that a given link was real.

The secret id IS the capability, so secret.ID is a struct whose every
accidental path — %v, %s, String(), slog, json.Marshal — emits a redacted
handle or refuses, and the raw value needs an explicit Value(). The first
version tried to prevent leaks by implementing no String() at all; its own test
caught that Go's fmt prints unexported fields anyway, so forbidding the method
had removed the control rather than the leak.

Operationally: structured JSON on stdout in the fleet's wire format, which
Vector already collects with no annotation; six hush_* metrics on the chassis
registry with no id, IP or path in any label; five alert rules wired into
vmalert. The public Ingress enumerates /, /s/ and /api/ so /metrics, /healthz
and /readyz share the port but are unreachable from the internet — no
basic-auth middleware to maintain and get wrong.

Dependencies are vendored because go-chassis is private: the Woodpecker test
step and the in-cluster Kaniko build both run -mod=vendor with GOPROXY=off and
hold no git credential.

cmd/hush-mcp is a stdio MCP server doing the same client-side crypto locally,
so using hush from an agent preserves the same guarantee as using it from a
browser.
2026-09-03 00:08:38 -06:00

119 lines
4.9 KiB
Markdown

# 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, pick a lifetime, press **Create link**.
3. Copy the link and send it however you like.
4. The recipient opens it, presses **Reveal**, and reads it once.
### 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. See [docs/MCP.md](docs/MCP.md).
## 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
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — how it works and why each choice
- [docs/DEPLOY.md](docs/DEPLOY.md) — pipeline, DNS, credentials, first deploy
- [docs/OPERATIONS.md](docs/OPERATIONS.md) — alert runbook, log queries, failure modes
- [docs/MCP.md](docs/MCP.md) — the MCP server and how to install it
## Development
```bash
make help # every target
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.