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.
191 lines
5.8 KiB
Go
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())
|
|
}
|
|
}
|