Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Using hush from an agent needed a clone and docs/MCP.md. It now needs one
command, and the instructions are served by the deployment itself.
`go install github.com/orchard9/hush/cmd/hush-mcp@latest` is the whole
install: cmd/hush-mcp imports only the standard library, so module graph
pruning never reaches the private go-chassis dependency cmd/hushd needs.
Verified against an empty module cache and the public proxy, then create ->
reveal end to end against production with the resulting binary.
The page carries the per-client configuration for Claude Code, Codex CLI,
Gemini CLI, VS Code, Claude Desktop, Cursor and omp. Each command was run
against the installed client rather than copied from documentation, which is
how the differences on it are there at all: VS Code's wrapper key is
`servers`, not `mcpServers`; gemini defaults to project scope, not user;
Claude Code rejects `--env` immediately before the server name.
The shared browser crypto moves from base.html into templates/crypto.html,
which the two pages that encrypt parse and this one does not. An empty
`{{define}}` cannot replace a non-empty one — text/template reads an empty
body as no definition — so the shell holds the call and the partial holds the
code, and the docs page ships no script at all.
Three things this exposed, fixed here:
- The public Ingress enumerates paths, so a handler without one 404s at the
edge while working in `make dev`. The Ingress is now its own manifest:
hush.yaml pins a `:bootstrap` image that does not exist, so re-applying it
to publish a path would roll the workload onto an unpullable image.
`make deploy-ingress` applies the route alone.
- release.sh guarded HEAD against `@{upstream}`, which is the GitHub mirror
here, while Kaniko clones Gitea. A commit pushed to one and not the other
would have built the previous commit silently. It now fetches and compares
the branch that actually gets built.
- smoke.sh checks that /mcp serves the install command, so a stale rollout or
an unexecutable template fails the release instead of being found later.
Confirmed it fails: against production before this deploy it reported 404.
215 lines
10 KiB
Markdown
215 lines
10 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)
|
|
GET /mcp MCP install instructions (static HTML, no script)
|
|
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 pages override the chassis CSP
|
|
|
|
The chassis policy is written for a JSON API: `default-src 'none';
|
|
frame-ancestors 'none'`. The pages are HTML with inline style, and the two that
|
|
encrypt also carry inline script, so `internal/web.render` replaces that header
|
|
with a per-response nonce policy:
|
|
|
|
```
|
|
default-src 'none'; script-src 'nonce-<r>'; style-src 'nonce-<r>';
|
|
connect-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'
|
|
```
|
|
|
|
Three decisions, each with a failure it prevents:
|
|
|
|
- **A header, and only a header.** Two policies delivered on one response
|
|
intersect, so a permissive `<meta>` cannot re-enable what the header forbids.
|
|
Shipping both is how the pages ended up with their own crypto and their own
|
|
`fetch` blocked while the `<meta>` read as permitted. `frame-ancestors` is
|
|
also ignored outright in `<meta>`, so it exists only as a header.
|
|
- **A nonce, not `'unsafe-inline'`.** The guarantee is that nothing but this
|
|
reviewed same-document script can reach the key in the fragment;
|
|
`'unsafe-inline'` would extend that permission to anything an injection got
|
|
onto the page.
|
|
- **Fresh per response, url-alphabet base64.** A reused nonce is worth
|
|
`'unsafe-inline'` to an attacker who can wait for the next load, and `+` or
|
|
`/` in the value would be escaped to character references inside the HTML
|
|
attribute, making what the browser enforces depend on entity decoding. The
|
|
nonce is fixed-length, so it adds no id-correlated variation to the reveal
|
|
page — `TestTheRevealPageDoesNotDiscloseWhetherASecretExists` compares the
|
|
page with it masked and asserts constant length.
|
|
|
|
The public Ingress routes `/` (exact), `/mcp` (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. A new public route is therefore two
|
|
changes — the handler and an Ingress path — and forgetting the second one is a
|
|
404 at the edge on a route that works in `make dev`.
|
|
|
|
## 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.
|