hush/internal/web/templates/base.html
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

95 lines
4.4 KiB
HTML

{{define "base.html"}}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hush</title>
<!-- No external origins: no CDN, no font host, no analytics. A third-party
script on this page could read the key out of the fragment, so the CSP
forbids one existing. 'unsafe-inline' covers the inline script and style
below, which are same-document and reviewed in this repo. -->
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'">
<meta name="referrer" content="no-referrer">
<style>
:root{color-scheme:dark;--bg:#0b0d10;--fg:#e7ebf0;--dim:#8b95a3;--line:#1e242c;--accent:#7fd1ae;--warn:#f0b866;--bad:#e8737d}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace;display:flex;min-height:100vh;align-items:center;justify-content:center;padding:24px}
main{width:100%;max-width:620px}
h1{font-size:20px;margin:0 0 4px;letter-spacing:.02em}
h1 span{color:var(--accent)}
p.lede{color:var(--dim);margin:0 0 24px;font-size:13px}
label{display:block;font-size:12px;color:var(--dim);margin:16px 0 6px;text-transform:uppercase;letter-spacing:.08em}
textarea,input,select,button{font:inherit;color:var(--fg);background:#12161b;border:1px solid var(--line);border-radius:6px;padding:10px 12px;width:100%}
textarea{min-height:150px;resize:vertical}
button{background:var(--accent);color:#06231a;border:0;font-weight:600;cursor:pointer;margin-top:20px;padding:12px}
button:hover{filter:brightness(1.08)}
button:disabled{opacity:.5;cursor:not-allowed;filter:none}
button.secondary{background:#12161b;color:var(--fg);border:1px solid var(--line);font-weight:400}
.row{display:flex;gap:12px}
.row>*{flex:1}
.out{margin-top:24px;padding:14px;border:1px solid var(--line);border-radius:6px;background:#0f1418;word-break:break-all;font-size:13px}
.note{color:var(--dim);font-size:12px;margin-top:10px}
.err{color:var(--bad)}
.warn{color:var(--warn)}
.ok{color:var(--accent)}
.hide{display:none}
footer{margin-top:28px;color:var(--dim);font-size:11px;border-top:1px solid var(--line);padding-top:12px}
a{color:var(--dim)}
</style>
</head>
<body>
<main>
{{template "content" .}}
<footer>
Encrypted in your browser. The key travels in the link's <code>#fragment</code>,
which browsers never send to a server — hush stores ciphertext it cannot open.
The link works once.
</footer>
</main>
<script>
// Shared crypto. AES-256-GCM via WebCrypto; the key is generated here, never
// transmitted, and carried only in the URL fragment.
//
// b64u: base64url without padding, matching Go's base64.RawURLEncoding on the
// server so the wire format has exactly one spelling.
const b64u = {
enc(bytes) {
let s = ""; for (const b of bytes) s += String.fromCharCode(b);
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
},
dec(str) {
const p = str.replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(p + "=".repeat((4 - (p.length % 4)) % 4));
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
},
};
async function seal(plaintext) {
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
// 96-bit nonce is the AES-GCM standard size and is generated per secret. It
// is prepended to the ciphertext rather than sent separately so a stored blob
// is self-contained.
const nonce = crypto.getRandomValues(new Uint8Array(12));
const body = new Uint8Array(await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: nonce }, key, new TextEncoder().encode(plaintext)));
const blob = new Uint8Array(nonce.length + body.length);
blob.set(nonce, 0); blob.set(body, nonce.length);
const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));
return { ciphertext: b64u.enc(blob), key: b64u.enc(rawKey) };
}
async function open(ciphertext, keyStr) {
const blob = b64u.dec(ciphertext);
const key = await crypto.subtle.importKey("raw", b64u.dec(keyStr), "AES-GCM", false, ["decrypt"]);
const plain = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: blob.slice(0, 12) }, key, blob.slice(12));
return new TextDecoder().decode(plain);
}
</script>
{{template "script" .}}
</body>
</html>{{end}}