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

92 lines
3.1 KiB
Go

package secret
import (
"encoding/base64"
"errors"
"strings"
"testing"
"time"
)
func TestValidateCiphertext(t *testing.T) {
valid := base64.RawURLEncoding.EncodeToString([]byte("nonce+ciphertext bytes"))
for _, tc := range []struct {
name string
in string
want error
}{
{"a real base64url blob", valid, nil},
{"empty", "", ErrCiphertextEmpty},
{"not base64url", "!!!not base64!!!", ErrCiphertextInvalid},
// Standard base64 uses + and /, which are not URL-safe. Accepting them
// would mean two spellings of one ciphertext and a client that works in
// one browser and not another.
{"standard base64 alphabet", "YWJj+/8=", ErrCiphertextInvalid},
{"padded", "YWJjZA==", ErrCiphertextInvalid},
{"at the cap", strings.Repeat("A", MaxCiphertextBytes), nil},
{"one byte over the cap", strings.Repeat("A", MaxCiphertextBytes+1), ErrCiphertextTooLarge},
} {
t.Run(tc.name, func(t *testing.T) {
err := ValidateCiphertext(tc.in)
if !errors.Is(err, tc.want) {
t.Fatalf("ValidateCiphertext() = %v, want %v", err, tc.want)
}
})
}
}
// The size check must happen BEFORE the decode, or a 64 KiB+ body costs a
// decoded copy before being refused — which is the cheap half of a memory DoS.
func TestOversizedCiphertextIsRefusedWithoutDecoding(t *testing.T) {
// Deliberately not valid base64. If the implementation decoded first, this
// would come back as ErrCiphertextInvalid instead of ErrCiphertextTooLarge.
huge := strings.Repeat("!", MaxCiphertextBytes+1)
if err := ValidateCiphertext(huge); !errors.Is(err, ErrCiphertextTooLarge) {
t.Fatalf("ValidateCiphertext(oversized invalid) = %v, want ErrCiphertextTooLarge — "+
"the size gate must precede the decode", err)
}
}
func TestResolveTTL(t *testing.T) {
for _, tc := range []struct {
name string
in time.Duration
want time.Duration
err error
}{
{"unspecified takes the default", 0, DefaultTTL, nil},
{"at the minimum", MinTTL, MinTTL, nil},
{"at the maximum", MaxTTL, MaxTTL, nil},
{"a normal day", 24 * time.Hour, 24 * time.Hour, nil},
{"below the minimum", MinTTL - time.Second, 0, ErrTTLOutOfRange},
{"above the maximum", MaxTTL + time.Second, 0, ErrTTLOutOfRange},
{"negative", -time.Hour, 0, ErrTTLOutOfRange},
{"absurd", 3650 * 24 * time.Hour, 0, ErrTTLOutOfRange},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := ResolveTTL(tc.in)
if !errors.Is(err, tc.err) {
t.Fatalf("ResolveTTL(%s) error = %v, want %v", tc.in, err, tc.err)
}
if err == nil && got != tc.want {
t.Fatalf("ResolveTTL(%s) = %s, want %s", tc.in, got, tc.want)
}
})
}
}
// An out-of-range TTL must be an ERROR and never a silent clamp. A caller who
// asked for 30 days and was quietly given 7 would believe their link outlives
// its real expiry, and would discover otherwise when the recipient could not
// open it.
func TestOutOfRangeTTLIsRefusedNotClamped(t *testing.T) {
got, err := ResolveTTL(30 * 24 * time.Hour)
if err == nil {
t.Fatalf("ResolveTTL(30d) silently returned %s instead of refusing", got)
}
if got != 0 {
t.Fatalf("ResolveTTL returned %s alongside an error; callers must not see a usable value", got)
}
}