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.
92 lines
3.1 KiB
Go
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)
|
|
}
|
|
}
|