hush/cmd/hush-mcp/crypto_test.go
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

142 lines
4.2 KiB
Go

package main
import (
"encoding/base64"
"os/exec"
"strings"
"testing"
)
func TestSealOpenRoundTrip(t *testing.T) {
for _, plain := range []string{
"hunter2",
"",
strings.Repeat("x", 40000),
"unicode: ✓ 漢字 🔐",
"multi\nline\nwith\ttabs",
} {
ct, key, err := seal(plain)
if err != nil {
t.Fatalf("seal(%d bytes): %v", len(plain), err)
}
got, err := open(ct, key)
if err != nil {
t.Fatalf("open: %v", err)
}
if got != plain {
t.Fatalf("round trip changed the plaintext (%d bytes)", len(plain))
}
}
}
func TestSealProducesTheBrowsersWireFormat(t *testing.T) {
ct, key, err := seal("x")
if err != nil {
t.Fatal(err)
}
// base64url, unpadded, matching base64.RawURLEncoding on the server and
// the b64u helper in templates/base.html. Three implementations, one
// spelling — a mismatch here means a link minted by one client cannot be
// opened by another.
for name, v := range map[string]string{"ciphertext": ct, "key": key} {
if strings.ContainsAny(v, "+/=") {
t.Fatalf("%s %q uses the standard base64 alphabet or padding; it must be base64url unpadded", name, v)
}
if _, err := base64.RawURLEncoding.DecodeString(v); err != nil {
t.Fatalf("%s does not decode as base64url: %v", name, err)
}
}
// 256-bit key.
raw, _ := base64.RawURLEncoding.DecodeString(key)
if len(raw) != 32 {
t.Fatalf("key is %d bytes, want 32 (AES-256)", len(raw))
}
// 96-bit nonce prepended, then at least the GCM tag.
blob, _ := base64.RawURLEncoding.DecodeString(ct)
if len(blob) < 12+16 {
t.Fatalf("ciphertext is %d bytes, too short for a 12-byte nonce plus a 16-byte tag", len(blob))
}
}
func TestOpenRefusesAWrongKeyAndTamperedCiphertext(t *testing.T) {
ct, _, err := seal("secret")
if err != nil {
t.Fatal(err)
}
_, otherKey, _ := seal("unrelated")
if _, err := open(ct, otherKey); err == nil {
t.Fatal("open() accepted a key that does not belong to this ciphertext")
}
// GCM is authenticated: a flipped byte must fail, not decrypt to garbage.
blob, _ := base64.RawURLEncoding.DecodeString(ct)
blob[len(blob)-1] ^= 0xff
_, key, _ := seal("x")
if _, err := open(base64.RawURLEncoding.EncodeToString(blob), key); err == nil {
t.Fatal("open() accepted tampered ciphertext")
}
if _, err := open("!!!not base64!!!", key); err == nil {
t.Fatal("open() accepted a non-base64url ciphertext")
}
if _, err := open(ct, "!!!"); err == nil {
t.Fatal("open() accepted a non-base64url key")
}
}
// Cross-implementation check: a blob sealed by this Go client must decrypt with
// Python's AES-GCM, and vice versa. This is what proves the MCP client, the
// browser and the smoke script really share one format rather than three
// self-consistent ones.
func TestWireFormatMatchesAnIndependentImplementation(t *testing.T) {
if _, err := exec.LookPath("python3"); err != nil {
t.Skip("python3 unavailable")
}
const plain = "cross-implementation-secret"
ct, key, err := seal(plain)
if err != nil {
t.Fatal(err)
}
// Go -> Python
out, err := exec.Command("python3", "-c", `
import base64, sys
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
u = lambda s: base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
blob, key = u(sys.argv[1]), u(sys.argv[2])
sys.stdout.write(AESGCM(key).decrypt(blob[:12], blob[12:], None).decode())
`, ct, key).Output()
if err != nil {
t.Skipf("python cryptography unavailable: %v", err)
}
if string(out) != plain {
t.Fatalf("python decrypted our ciphertext to %q, want %q", out, plain)
}
// Python -> Go
pyOut, err := exec.Command("python3", "-c", `
import base64, os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = AESGCM.generate_key(bit_length=256); nonce = os.urandom(12)
blob = nonce + AESGCM(key).encrypt(nonce, b"from-python", None)
b = lambda x: base64.urlsafe_b64encode(x).decode().rstrip("=")
print(b(blob), b(key))
`).Output()
if err != nil {
t.Fatalf("python encrypt failed: %v", err)
}
parts := strings.Fields(string(pyOut))
if len(parts) != 2 {
t.Fatalf("unexpected python output %q", pyOut)
}
got, err := open(parts[0], parts[1])
if err != nil {
t.Fatalf("could not open python's ciphertext: %v", err)
}
if got != "from-python" {
t.Fatalf("opened python's ciphertext to %q", got)
}
}