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.
148 lines
4.1 KiB
Go
148 lines
4.1 KiB
Go
// Package config is the typed, validated environment loader used at the
|
|
// composition root (services/*/main.go, workers/*/main.go) — the ONLY place env
|
|
// is read. A Loader accumulates parse/validation errors so boot fails closed
|
|
// with one actionable message instead of silently running on bad config.
|
|
//
|
|
// Secrets (DB passwords, API keys) do NOT belong here — they come from a
|
|
// secret source (manager / file). This loads non-secret wiring only.
|
|
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Loader reads env vars with defaults and validation, collecting every error.
|
|
type Loader struct {
|
|
errs []error
|
|
}
|
|
|
|
// New returns an empty Loader.
|
|
func New() *Loader { return &Loader{} }
|
|
|
|
// String returns the env value or def when unset/empty.
|
|
func (l *Loader) String(key, def string) string {
|
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
// Strings splits a comma-separated env value, trimming each entry and dropping
|
|
// empties, or returns def when unset. Used for allowlists (CORS origins,
|
|
// trusted proxies) where a platform has more than one legitimate value and a
|
|
// single-string field would silently serve only the first.
|
|
func (l *Loader) Strings(key string, def []string) []string {
|
|
raw := strings.TrimSpace(os.Getenv(key))
|
|
if raw == "" {
|
|
return def
|
|
}
|
|
parts := strings.Split(raw, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
if v := strings.TrimSpace(p); v != "" {
|
|
out = append(out, v)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return def
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Required returns the env value or records an error when unset/empty.
|
|
func (l *Loader) Required(key string) string {
|
|
v := strings.TrimSpace(os.Getenv(key))
|
|
if v == "" {
|
|
l.errs = append(l.errs, fmt.Errorf("%s is required", key))
|
|
}
|
|
return v
|
|
}
|
|
|
|
// Int parses an integer env value, recording an error on a malformed value.
|
|
func (l *Loader) Int(key string, def int) int {
|
|
raw := strings.TrimSpace(os.Getenv(key))
|
|
if raw == "" {
|
|
return def
|
|
}
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
l.errs = append(l.errs, fmt.Errorf("%s: invalid int %q", key, raw))
|
|
return def
|
|
}
|
|
return n
|
|
}
|
|
|
|
// Int64 parses a 64-bit integer env value, recording an error on a malformed
|
|
// value. Separate from Int because byte-size limits (body caps, artifact
|
|
// ceilings) legitimately exceed a 32-bit int on some platforms.
|
|
func (l *Loader) Int64(key string, def int64) int64 {
|
|
raw := strings.TrimSpace(os.Getenv(key))
|
|
if raw == "" {
|
|
return def
|
|
}
|
|
n, err := strconv.ParseInt(raw, 10, 64)
|
|
if err != nil {
|
|
l.errs = append(l.errs, fmt.Errorf("%s: invalid int64 %q", key, raw))
|
|
return def
|
|
}
|
|
return n
|
|
}
|
|
|
|
// Port parses and bounds-checks a TCP port (1..65535).
|
|
func (l *Loader) Port(key string, def int) int {
|
|
n := l.Int(key, def)
|
|
if n < 1 || n > 65535 {
|
|
l.errs = append(l.errs, fmt.Errorf("%s: port %d out of range 1..65535", key, n))
|
|
return def
|
|
}
|
|
return n
|
|
}
|
|
|
|
// Duration parses a Go duration (e.g. 5s, 1h), recording an error if malformed.
|
|
func (l *Loader) Duration(key string, def time.Duration) time.Duration {
|
|
raw := strings.TrimSpace(os.Getenv(key))
|
|
if raw == "" {
|
|
return def
|
|
}
|
|
d, err := time.ParseDuration(raw)
|
|
if err != nil {
|
|
l.errs = append(l.errs, fmt.Errorf("%s: invalid duration %q", key, raw))
|
|
return def
|
|
}
|
|
return d
|
|
}
|
|
|
|
// Bool parses a boolean (1/t/true/0/f/false), recording an error if malformed.
|
|
func (l *Loader) Bool(key string, def bool) bool {
|
|
raw := strings.TrimSpace(os.Getenv(key))
|
|
if raw == "" {
|
|
return def
|
|
}
|
|
b, err := strconv.ParseBool(raw)
|
|
if err != nil {
|
|
l.errs = append(l.errs, fmt.Errorf("%s: invalid bool %q", key, raw))
|
|
return def
|
|
}
|
|
return b
|
|
}
|
|
|
|
// OneOf returns the env value when it is in allowed, else records an error.
|
|
func (l *Loader) OneOf(key, def string, allowed ...string) string {
|
|
v := l.String(key, def)
|
|
for _, a := range allowed {
|
|
if v == a {
|
|
return v
|
|
}
|
|
}
|
|
l.errs = append(l.errs, fmt.Errorf("%s: %q not one of %s", key, v, strings.Join(allowed, "|")))
|
|
return def
|
|
}
|
|
|
|
// Err returns the joined validation errors, or nil when the config is clean.
|
|
func (l *Loader) Err() error { return errors.Join(l.errs...) }
|