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.
250 lines
8.6 KiB
YAML
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 }
|