hush/docs/ARCHITECTURE.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

181 lines
8.3 KiB
Markdown

# Architecture
One Go binary, one Redis key per secret, no database. The interesting parts are
all about *where the key sits* and *what destroys the ciphertext*.
## Components
```
browser ──► Traefik ──► hushd (projects ns, 1 replica) ──► Redis (databases ns, db 5)
├─ stdout JSON ──► Vector (DaemonSet) ──► VictoriaLogs
└─ /metrics ─────► vmagent ──► vmsingle ──► vmalert ──► Alertmanager
```
`hushd` holds no durable state. Redis holds every secret and nothing else.
## The zero-knowledge split
```
create: plaintext ──[AES-256-GCM in the browser]──► ciphertext ──► POST /api/secrets
key ──────────────────────────────────────► URL fragment, never sent
reveal: POST /api/secrets/{id}/reveal ──► ciphertext ──[decrypt in browser]──► plaintext
key read from location.hash
```
The fragment is the whole trick. Per RFC 3986 §3.5 the fragment is a client-side
construct: browsers do not put it in the request line, so it never reaches
Traefik, hushd, Redis, an access log, or a proxy. hushd receives a 256-bit AES-GCM
ciphertext with a prepended 96-bit nonce and has no key material at any point.
Consequences worth stating plainly:
- A Redis dump is worthless. A hushd core dump is worthless. Our own operators
cannot read a secret, and neither can anyone who compromises the service.
- **A URL in someone's browser history contains the key.** The fragment is not
transmitted, but it *is* stored locally. This is the residual exposure and it
is why TTLs are short.
- There is deliberately no server-side-encryption fallback mode. A second mode
where the server sees plaintext would mean nobody could tell, from a link,
which guarantee they had.
## Why GET never touches storage
`GET /s/{id}` renders a static page and makes zero calls to Redis. It does not
even check whether the id exists.
That is not laziness — it is the only way to be correct in the presence of link
previewers. Slack, Teams, WhatsApp, iMessage and Outlook Safe Links fetch URLs
before a human sees them. Any design that destroys on `GET` destroys most secrets
in transit. Bot user-agent detection is a losing arms race; removing the
side effect from `GET` is not.
A secondary benefit: because `GET` does not look the id up, the reveal page cannot
leak whether an id exists. Existence is only ever answered by a `POST`, and that
answer is identical for missing, revealed and expired.
## Storage and destruction
One key per secret:
```
key hush:s:<id> id = 256 bits from crypto/rand, base64url (43 chars)
value <ciphertext> opaque bytes, ≤ 64 KiB
write SET key val EX <ttl> NX
read GETDEL key
```
`GETDEL` (Redis 6.2+; the cluster runs 7.4.8) is atomic, which is the reason it
is used instead of `GET` followed by `DEL`. Two people opening the same link
simultaneously cannot both receive the plaintext — exactly one `GETDEL` returns
the value and the other returns nil. A `GET`+`DEL` pair has a window between the
two commands where both callers succeed, and for a one-time secret that window is
the entire product.
`NX` on write means an id collision never overwrites an existing secret. At 256
bits of entropy a collision will not happen; the flag costs nothing and turns a
theoretical silent overwrite into a visible error.
TTL is Redis-native, so expiry needs no sweeper, no cron and nothing to wedge.
### Eviction is an availability risk, not a confidentiality one
The shared Redis runs `maxmemory-policy allkeys-lru` with `maxmemory 256MiB`.
Under memory pressure Redis may evict a hush key **before** its TTL fires. That
means a secret can become unavailable early.
It cannot become *more* available: eviction only ever deletes. So the failure mode
is "your recipient has to ask you again", never "the secret outlived its TTL" and
never "someone read it twice". For a secret courier that is the correct direction
to fail, and it is why `410 gone` deliberately does not distinguish causes — the
user-visible contract is identical either way.
Operationally this is watched via `HushRedisUnreachable` and the Redis memory
alerts, not by trying to tell eviction and reveal apart. See
[OPERATIONS.md](OPERATIONS.md).
## Identifiers and what gets logged
The id **is** the capability. Anyone holding it can reveal the secret, so it is
treated like a bearer token:
- Never logged. Not at debug, not in an error, not in a panic.
- The log correlation handle is `sid = sha256(id)[:12]` — enough to follow one
secret's create → reveal → gone across a corpus, useless for revealing it.
- Never in a metric label (that would put it in the time series index forever).
`internal/secret.ID.LogHandle()` is the only way to get a loggable form, and the
`ID` type does not implement `String()` or `MarshalText()`, so it cannot be
accidentally interpolated into a log line or JSON body. That is enforced by
`internal/secret/id_test.go`.
The chassis logger additionally redacts any field *named* `secret`, `token`,
`password`, `api_key`, `authorization` and friends. Field names here avoid those
words entirely (`ciphertext`, `sid`, `ttl_seconds`) so nothing depends on that
backstop.
## Request path
```
GET / create page (static HTML+JS, no storage access)
GET /s/{id} reveal page (static HTML+JS, no storage access)
POST /api/secrets store ciphertext rate limited
POST /api/secrets/{id}/reveal GETDEL, destroy, return once rate limited
GET /healthz liveness — 200 while draining
GET /readyz readiness — Redis PING, 503 while draining
GET /metrics Prometheus
```
Built on `github.com/orchard9/go-chassis`, which supplies routing, request ids,
the panic recovery envelope, RED metrics, secure headers, the two-phase drain,
and `/healthz`, `/readyz`, `/metrics`. hush contributes handlers, a store, a
rate limiter and templates — not a framework.
The public Ingress routes `/` (exact), `/s/` and `/api/` only. `/metrics`,
`/healthz` and `/readyz` share the port but are unreachable from the internet;
vmagent scrapes the pod IP directly. This is why there is no metrics basic-auth
middleware to maintain.
## Abuse posture
Create is anonymous by design, which makes the service a free blob host and a
phishing kit borrowing a `threesix.ai` name. Mitigations, all cheap:
| Control | Value |
|---|---|
| Ciphertext cap | 64 KiB, enforced before Redis |
| Request body cap | 128 KiB, enforced by the chassis at the edge |
| TTL clamp | 5m … 7d, out-of-range is a 422, not a silent clamp |
| Rate limit | 30 creates / 10 min / IP, Redis fixed-window |
| Id entropy | 256 bits — enumeration is not a threat model |
| No listing route | there is no way to ask "what secrets exist" |
| Identical `gone` | missing, revealed and expired are one response |
If it is ever abused, `HUSH_REQUIRE_AUTH=true` puts create behind the chassis
authenticator while leaving reveal anonymous — the asymmetry the design assumes.
Reveal must stay anonymous: the recipient is external and has no credential.
## Failure modes
| Failure | Behaviour |
|---|---|
| Redis down | `/readyz` 503, pod leaves the Service, creates and reveals 503. No secret is lost that was already written. |
| Redis evicts a key early | That link returns `410 gone`. Sender must re-send. |
| hushd restarts | Nothing lost; all state is in Redis. |
| Two simultaneous reveals | Exactly one wins, atomically. |
| Body over 128 KiB | 413 at the edge, never reaches a handler. |
| Ciphertext over 64 KiB | 422 `ciphertext_too_large`. |
| Malformed base64 | 422 `ciphertext_invalid`. hushd validates the encoding but cannot validate the plaintext. |
| Clock skew | TTL is Redis-relative, so skew between hushd and the browser cannot extend a secret's life. |
## What is deliberately absent
Accounts. Passphrases on top of the link. File uploads. Multi-read links. An
audit UI. Email delivery. Each is a real request and each doubles the surface.
The one with a genuine argument is **notify-on-read**: it confirms delivery and,
if it fires before the recipient says they opened it, that is a compromise
signal. It needs an email path, `notify` already exists to provide one, and it is
the first thing to add if hush proves useful.