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.
140 lines
4.2 KiB
Go
140 lines
4.2 KiB
Go
// Package logging is the core structured logger: one JSON object per line on
|
|
// stdout, ready for any log agent (Fluent Bit, vector, the cloud's native
|
|
// collector) to ship. Fields: ts (ISO-8601 UTC, ms, Z), level, service + env
|
|
// (the only indexed stream fields — keep them closed enums), msg, plus caller
|
|
// attrs. Secrets/PII MUST NOT be logged; forbiddenKeys redacts common offenders
|
|
// as defense-in-depth behind code review + lint. See patterns/go-chassis.md.
|
|
package logging
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// LevelCritical sits above slog.LevelError; it maps to the "critical" enum and
|
|
// is reserved for fail-closed boot/serve refusal (wire it to a page).
|
|
const LevelCritical = slog.Level(12)
|
|
|
|
// Config selects the closed-enum stream fields the log store indexes.
|
|
type Config struct {
|
|
Service string // closed enum, keep small (e.g. api, job-worker)
|
|
Env string // dev | staging | prod
|
|
}
|
|
|
|
// New returns the core logger writing the JSON wire format to stdout.
|
|
func New(cfg Config) *slog.Logger { return newTo(os.Stdout, cfg) }
|
|
|
|
// NewTo returns the core logger writing the JSON wire format to w. Server mode
|
|
// uses New (stdout); a bounded maintenance command uses this to send its
|
|
// operational diagnostics to stderr, keeping stdout a clean machine-readable
|
|
// result stream a caller can parse without stripping log lines out of it.
|
|
func NewTo(w io.Writer, cfg Config) *slog.Logger { return newTo(w, cfg) }
|
|
|
|
func newTo(w io.Writer, cfg Config) *slog.Logger {
|
|
h := slog.NewJSONHandler(w, &slog.HandlerOptions{
|
|
Level: slog.LevelDebug,
|
|
ReplaceAttr: replace,
|
|
})
|
|
return slog.New(h).With("service", cfg.Service, "env", cfg.Env)
|
|
}
|
|
|
|
func replace(groups []string, a slog.Attr) slog.Attr {
|
|
if forbiddenKeys[a.Key] { // defense-in-depth secret/PII redaction
|
|
return slog.String(a.Key, "[REDACTED]")
|
|
}
|
|
// The built-in rewrites below apply only to the record's own time/level
|
|
// attrs, which slog always passes at the top level. A caller attr that
|
|
// happens to be named "time" or "level" — log.Info("m", "level", "high") —
|
|
// arrives here too, so both the group depth and the value kind are checked
|
|
// before converting. An unchecked assertion here panics the process inside
|
|
// the logger every binary depends on.
|
|
if len(groups) > 0 {
|
|
return a
|
|
}
|
|
switch a.Key {
|
|
case slog.TimeKey:
|
|
if a.Value.Kind() != slog.KindTime {
|
|
return a
|
|
}
|
|
// ts: ISO-8601 UTC, ms precision, Z suffix.
|
|
a.Key = "ts"
|
|
a.Value = slog.StringValue(a.Value.Time().UTC().Format("2006-01-02T15:04:05.000Z07:00"))
|
|
case slog.LevelKey:
|
|
lvl, ok := a.Value.Any().(slog.Level)
|
|
if !ok {
|
|
return a
|
|
}
|
|
a.Key = "level"
|
|
a.Value = slog.StringValue(levelString(lvl))
|
|
}
|
|
return a
|
|
}
|
|
|
|
func levelString(l slog.Level) string {
|
|
switch {
|
|
case l >= LevelCritical:
|
|
return "critical"
|
|
case l >= slog.LevelError:
|
|
return "error"
|
|
case l >= slog.LevelWarn:
|
|
return "warn"
|
|
case l >= slog.LevelInfo:
|
|
return "info"
|
|
default:
|
|
return "debug"
|
|
}
|
|
}
|
|
|
|
// forbiddenKeys are field names that MUST NOT reach the log corpus. Extend it
|
|
// with YOUR product's sensitive fields (PII, PII, financial, tokens).
|
|
var forbiddenKeys = map[string]bool{
|
|
"password": true, "passwd": true, "secret": true, "token": true,
|
|
"api_key": true, "apikey": true, "authorization": true, "cookie": true,
|
|
"ssn": true, "email": true, "phone": true,
|
|
}
|
|
|
|
var (
|
|
fallbackMu sync.RWMutex
|
|
fallbackLog *slog.Logger
|
|
)
|
|
|
|
// SetFallback installs the logger From returns when no request-scoped logger is
|
|
// in context. Set once at boot from the composition root.
|
|
func SetFallback(l *slog.Logger) {
|
|
fallbackMu.Lock()
|
|
defer fallbackMu.Unlock()
|
|
fallbackLog = l
|
|
}
|
|
|
|
func fallback() *slog.Logger {
|
|
fallbackMu.RLock()
|
|
l := fallbackLog
|
|
fallbackMu.RUnlock()
|
|
if l != nil {
|
|
return l
|
|
}
|
|
return New(Config{Service: "unknown", Env: "dev"})
|
|
}
|
|
|
|
// Critical logs at the fail-closed level (boot/serve refusal). Bind to a page.
|
|
func Critical(ctx context.Context, l *slog.Logger, msg string, args ...any) {
|
|
l.Log(ctx, LevelCritical, msg, args...)
|
|
}
|
|
|
|
// Env normalizes an APP_ENV value (local|dev|staging|prod, or a gcp-* / aws-*
|
|
// prefix) to the log env enum.
|
|
func Env(appEnv string) string {
|
|
switch {
|
|
case strings.HasSuffix(appEnv, "prod"):
|
|
return "prod"
|
|
case strings.HasSuffix(appEnv, "staging"):
|
|
return "staging"
|
|
default:
|
|
return "dev"
|
|
}
|
|
}
|