hush/deployments/k8s/hush.yaml
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

250 lines
8.6 KiB
YAML

# hush on the orchard9 k3s cluster. Apply this BEFORE the first push, because
# the pipeline's deploy step runs `kubectl set image` and needs a Deployment to
# set it on.
#
# KUBECONFIG=~/.kube/orchard9-k3sf.yaml kubectl apply -f deployments/k8s/hush.yaml
#
# Everything hush needs is here: the credential, the workload, the Service, the
# network boundary and the public route.
---
# The Redis credential. hush connects as its OWN Redis ACL user, scoped to
# `~hush:*` with a minimal command set (+ping +set +getdel +incr +pexpire
# +select), so a bug in hush cannot read or write another tenant's keys and a
# compromise of hush cannot enumerate the keyspace.
#
# `+getdel` is the one that needs saying out loud: no other service's ACL user
# has it, because no other service needs an atomic read-and-destroy. Omitting it
# makes every reveal fail NOPERM while creates keep working — a service that
# accepts secrets and cannot deliver them.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: hush-credentials
namespace: projects
spec:
refreshInterval: 1h
secretStoreRef:
name: gcp-secret-manager
kind: ClusterSecretStore
target:
name: hush-credentials
creationPolicy: Owner
dataFrom:
- extract:
key: k3sf-hush-credentials
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: hush
namespace: projects
labels:
app: hush
spec:
# One replica is sufficient and not a bottleneck: every request is a single
# Redis round trip and all state lives in Redis, so this scales horizontally
# whenever it needs to. The rate limiter is already Redis-backed for exactly
# that reason.
replicas: 1
selector:
matchLabels:
app: hush
strategy:
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
metadata:
labels:
app: hush
annotations:
# Metrics scraping. All three are required, and the port must EQUAL a
# declared containerPort as a string — vmagent's relabel config uses
# `keepequal`, so a mismatch silently drops the target with no error
# and no up=0 to alert on.
prometheus.io/scrape: "true"
prometheus.io/port: "18500"
prometheus.io/path: /metrics
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
# The chassis drains in two phases — flip readiness to 503, wait 5s for
# the load balancer to notice, then shut down with a 25s deadline. That is
# 30s, so the grace period must exceed it or the kubelet SIGKILLs mid-drain
# and in-flight reveals are lost.
terminationGracePeriodSeconds: 45
containers:
- name: hushd
# `bootstrap` is a placeholder, replaced by CI's `kubectl set image`
# with the commit-SHA tag on the first push. It is deliberately NOT
# `:latest`: the cluster's stable-controller-images admission policy
# refuses latest/main/master/dev/edge/canary/nightly/snapshot, because
# a floating tag cannot pin a rollback. Until the first pipeline run
# this image does not exist and the pod sits in ImagePullBackOff,
# which is the expected bootstrap state.
image: registry.threesix.ai/hush/api:bootstrap
ports:
- name: http
containerPort: 18500
env:
- name: APP_ENV
value: prod
- name: HUSH_PORT
value: "18500"
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: hush-credentials
key: REDIS_URL
# One Traefik hop sits in front, so the client IP is the last entry
# of X-Forwarded-For. Too high lets a caller spoof their IP past the
# rate limit; too low rate-limits the ingress itself and throttles
# every user together.
- name: HUSH_TRUSTED_PROXY_HOPS
value: "1"
- name: HUSH_RATE_LIMIT_CREATES
value: "30"
- name: HUSH_RATE_LIMIT_WINDOW
value: 10m
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
# Liveness stays 200 during the drain by design, so this restarts the
# pod only when the process is genuinely wedged — never merely because
# it is shutting down or because Redis is down.
livenessProbe:
httpGet: { path: /healthz, port: http }
initialDelaySeconds: 3
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
# Readiness pings Redis. A Redis outage takes hush out of the Service
# rather than leaving it to serve 500s from a pod the load balancer
# still trusts.
readinessProbe:
httpGet: { path: /readyz, port: http }
initialDelaySeconds: 2
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 2
resources:
requests: { memory: 32Mi, cpu: 20m }
# 128Mi is generous for a service whose largest allocation is a
# 64 KiB ciphertext, and leaves headroom for Go's GC pacing.
limits: { memory: 128Mi, cpu: 500m }
---
apiVersion: v1
kind: Service
metadata:
name: hush
namespace: projects
labels:
app: hush
spec:
selector:
app: hush
ports:
- name: http
port: 80
targetPort: http
---
# Default-deny both directions, then open exactly what hush needs. Written as
# one policy because the ingress and egress rules are a single statement about
# this pod: Traefik in, DNS and Redis out, nothing else.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: hush
namespace: projects
spec:
podSelector:
matchLabels:
app: hush
policyTypes: [Ingress, Egress]
ingress:
# Public traffic, via Traefik only.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
app.kubernetes.io/name: traefik
ports:
- { protocol: TCP, port: 18500 }
# vmagent scrapes /metrics on the pod IP directly. Without this rule the
# target is discovered and every scrape is connection-refused, which shows
# up as up=0 and fires ScrapeTargetDown rather than as a policy error.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: observability
ports:
- { protocol: TCP, port: 18500 }
egress:
# DNS. Redis is a headless Service, so its name resolves to a pod IP that
# changes when Redis restarts — this must be re-resolvable, not cached.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 }
# Redis, and nothing else. hush makes no other outbound connection: it does
# not fetch, does not call a provider, and does not ship its own logs
# (Vector reads stdout off the node filesystem).
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: databases
ports:
- { protocol: TCP, port: 6379 }
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hush
namespace: projects
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts: [hush.threesix.ai]
secretName: hush-tls
rules:
- host: hush.threesix.ai
http:
paths:
# The paths are enumerated deliberately, and `/` is Exact rather than
# Prefix. A Prefix `/` would route EVERYTHING, publishing /metrics,
# /healthz and /readyz to the internet. /metrics leaks how many
# secrets are created and when; the others are just noise. Enumerating
# instead means Traefik 404s them at the edge and there is no
# basic-auth middleware to maintain and get wrong.
- path: /
pathType: Exact
backend:
service:
name: hush
port: { name: http }
- path: /s/
pathType: Prefix
backend:
service:
name: hush
port: { name: http }
- path: /api/
pathType: Prefix
backend:
service:
name: hush
port: { name: http }