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.
142 lines
4.2 KiB
Go
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)
|
|
}
|
|
}
|