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

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"
}
}