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.
132 lines
5.4 KiB
Markdown
132 lines
5.4 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, 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.
|
|
|
|
```bash
|
|
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](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
|
|
|
|
- [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, its install, and why it is local
|
|
|
|
## Development
|
|
|
|
```bash
|
|
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.
|