hush/internal/secret/id_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

191 lines
5.8 KiB
Go

package secret
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"strings"
"testing"
)
// The id IS the capability, so every path that could print one is checked here.
//
// The first version of this test forbade String() outright, on the theory that
// a type with no Stringer cannot be interpolated. That was wrong and this test
// caught it: Go's fmt prints unexported struct fields regardless, so `%v` was
// emitting live ids. The design now makes the REDACTED form the default on
// every accidental path and requires Value() for the raw one.
//
// Read this test first if you are wondering why ID has a String() that throws
// away information and a MarshalJSON that refuses.
func TestNoAccidentalPathEmitsTheRawID(t *testing.T) {
id, err := NewID()
if err != nil {
t.Fatal(err)
}
raw := id.Value()
for _, tc := range []struct {
name string
got string
}{
{"fmt %v", fmt.Sprintf("%v", id)},
{"fmt %s", fmt.Sprintf("%s", id)},
{"fmt %+v", fmt.Sprintf("%+v", id)},
{"fmt %#v of a wrapper struct", fmt.Sprintf("%v", struct{ ID ID }{id})},
{"String()", id.String()},
{"LogValue()", id.LogValue().String()},
{"inside a slice", fmt.Sprintf("%v", []ID{id})},
{"inside a map", fmt.Sprintf("%v", map[string]ID{"k": id})},
} {
t.Run(tc.name, func(t *testing.T) {
if strings.Contains(tc.got, raw) {
t.Fatalf("%s emitted the live id (%q) — this value would appear in a log line "+
"and anyone reading it could reveal the secret", tc.name, tc.got)
}
if !strings.Contains(tc.got, id.LogHandle()) {
t.Fatalf("%s = %q, which carries neither the id nor its handle; a log line "+
"with no handle cannot be correlated", tc.name, tc.got)
}
})
}
}
// A real slog handler, because that is where ids actually pass through.
func TestSlogEmitsTheHandleNotTheID(t *testing.T) {
id, err := NewID()
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(&buf, nil))
// Every shape a call site might use, including the careless one.
log.Info("secret.created", "id", id)
log.Info("secret.created", "sid", id.LogHandle())
log.With("id", id).Info("secret.revealed")
log.Info("secret.gone", "ids", []ID{id})
out := buf.String()
if strings.Contains(out, id.Value()) {
t.Fatalf("slog output contains the live id:\n%s", out)
}
if !strings.Contains(out, id.LogHandle()) {
t.Fatalf("slog output contains no correlation handle:\n%s", out)
}
}
// Serialising an ID must FAIL rather than quietly emit either form. The raw
// value would leak a capability out of any response struct embedding an ID;
// the redacted value would produce a response that looks like it carries an id
// and does not, turning a server bug into a broken link for the recipient.
func TestMarshallingAnIDIsAnError(t *testing.T) {
id, err := NewID()
if err != nil {
t.Fatal(err)
}
if b, err := json.Marshal(id); err == nil {
t.Fatalf("json.Marshal(ID) succeeded with %s; it must refuse", b)
}
// And from inside a struct, which is how it would actually happen.
if b, err := json.Marshal(struct {
ID ID `json:"id"`
}{id}); err == nil {
t.Fatalf("marshalling a struct containing an ID succeeded with %s; it must refuse", b)
}
}
func TestNewIDIsUnguessablyWideAndUnique(t *testing.T) {
seen := make(map[string]bool, 1000)
for range 1000 {
id, err := NewID()
if err != nil {
t.Fatal(err)
}
if len(id.Value()) != 43 {
t.Fatalf("id %q is %d chars, want 43 (256 bits base64url unpadded)", id.Value(), len(id.Value()))
}
if seen[id.Value()] {
t.Fatal("NewID repeated a value in 1000 draws — entropy is broken")
}
seen[id.Value()] = true
}
}
func TestParseIDRoundTripsAndRefusesEverythingElse(t *testing.T) {
id, err := NewID()
if err != nil {
t.Fatal(err)
}
back, err := ParseID(id.Value())
if err != nil {
t.Fatalf("ParseID rejected a freshly minted id: %v", err)
}
if back.Value() != id.Value() {
t.Fatalf("round trip changed the id: %q -> %q", id.Value(), back.Value())
}
for _, bad := range []struct{ name, in string }{
{"empty", ""},
{"too short", "abc"},
{"one char short", id.Value()[:42]},
{"one char long", id.Value() + "a"},
{"not base64url", strings.Repeat("!", 43)},
// A shorter secret encoded in a 43-char field would widen the space a
// scanner must cover, so the DECODED length is checked too.
{"padded base64", strings.Repeat("A", 40) + "==="},
{"path traversal", "../../etc/passwd"},
{"redis glob", strings.Repeat("*", 43)},
} {
t.Run(bad.name, func(t *testing.T) {
if _, err := ParseID(bad.in); err == nil {
t.Fatalf("ParseID(%q) accepted a value NewID cannot produce", bad.in)
}
})
}
}
func TestLogHandleIsStableShortAndNotTheID(t *testing.T) {
id, err := NewID()
if err != nil {
t.Fatal(err)
}
h := id.LogHandle()
if len(h) != 12 {
t.Fatalf("LogHandle() = %q, want 12 hex chars", h)
}
if id.LogHandle() != h {
t.Fatal("LogHandle is not stable across calls, so log lines for one secret will not correlate")
}
if strings.Contains(h, id.Value()) || strings.Contains(id.Value(), h) {
t.Fatalf("LogHandle %q overlaps the raw id %q — it must be a hash, not a prefix", h, id.Value())
}
// Different ids must not collide into one handle, or correlation is wrong.
other, err := NewID()
if err != nil {
t.Fatal(err)
}
if other.LogHandle() == h {
t.Fatal("two ids produced the same LogHandle")
}
if (ID{}).LogHandle() != "" {
t.Fatal("the zero ID must produce an empty handle, not a hash of the empty string")
}
}
func TestStorageKeyStaysInsideTheACLPrefix(t *testing.T) {
id, err := NewID()
if err != nil {
t.Fatal(err)
}
// The Redis ACL user is scoped to ~hush:*. A key built outside that prefix
// is refused by the server, so this test is what keeps a rename from
// turning every write into a NOPERM at runtime.
if !strings.HasPrefix(id.StorageKey(), "hush:") {
t.Fatalf("StorageKey() = %q, which is outside the ~hush:* ACL scope", id.StorageKey())
}
}