docs/ARCHITECTURE.md records why internal/web.render replaces the chassis JSON-API policy with a per-response nonce policy, and the failure each of the three decisions prevents: a header-only policy because two policies on one response intersect, a nonce instead of 'unsafe-inline' because the guarantee is that only the reviewed same-document script reaches the fragment key, and a fresh url-alphabet value because a reused nonce is worth 'unsafe-inline' to anyone who waits for the next load and + or / would make enforcement depend on entity decoding. README.md now names the buttons the page actually renders and says outright that there is no lifetime picker — the server's 24h default applies and ttl_seconds is where a caller chooses. base.html drops the opacity transition; nothing animates opacity. .dockerignore is an allowlist, because the build stage COPYs only go.mod, go.sum, vendor/, cmd/ and internal/. A blocklist forgets the file nobody predicted, and for this service that file is a secret. .gitignore grows the same protection for the working tree.
9.9 KiB
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.
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 pages override the chassis CSP
The chassis policy is written for a JSON API: default-src 'none'; frame-ancestors 'none'. The two pages are HTML with inline script and inline
style, 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 ownfetchblocked while the<meta>read as permitted.frame-ancestorsis 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 —TestTheRevealPageDoesNotDiscloseWhetherASecretExistscompares the page with it masked and asserts constant length.
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.