hush/vendor/github.com/orchard9/go-chassis/config/config.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

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...) }