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.
95 lines
4.5 KiB
Bash
Executable File
95 lines
4.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# End-to-end proof against a RUNNING hushd, doing the real client-side crypto.
|
|
#
|
|
# This is not a mock: it generates an AES-256-GCM key, encrypts a plaintext,
|
|
# posts only the ciphertext, reveals it once, decrypts it, and compares. Then it
|
|
# checks the three properties the design rests on:
|
|
#
|
|
# 1. a second reveal is 410 gone
|
|
# 2. GET on the reveal page does NOT consume the secret (link previewers)
|
|
# 3. a malformed id and a missing id are indistinguishable
|
|
#
|
|
# Usage: BASE=http://127.0.0.1:18500 ./scripts/smoke.sh
|
|
set -euo pipefail
|
|
|
|
BASE="${BASE:-http://127.0.0.1:18500}"
|
|
PLAINTEXT="${PLAINTEXT:-hunter2-$(date +%s)-$RANDOM}"
|
|
|
|
pass() { printf ' \033[32mok\033[0m %s\n' "$1"; }
|
|
fail() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; exit 1; }
|
|
|
|
command -v python3 >/dev/null || { echo "python3 is required for the crypto half"; exit 1; }
|
|
|
|
echo "hush smoke against $BASE"
|
|
|
|
# --- encrypt exactly as the browser does -----------------------------------
|
|
# AES-256-GCM, 96-bit nonce prepended, base64url unpadded. Same wire format as
|
|
# templates/base.html, which is what makes this a real client.
|
|
read -r CIPHERTEXT KEY <<<"$(python3 - "$PLAINTEXT" <<'PY'
|
|
import base64, os, sys
|
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
key = AESGCM.generate_key(bit_length=256)
|
|
nonce = os.urandom(12)
|
|
blob = nonce + AESGCM(key).encrypt(nonce, sys.argv[1].encode(), None)
|
|
b = lambda x: base64.urlsafe_b64encode(x).decode().rstrip("=")
|
|
print(b(blob), b(key))
|
|
PY
|
|
)"
|
|
[ -n "$CIPHERTEXT" ] || fail "could not encrypt (is 'cryptography' installed? pip install cryptography)"
|
|
pass "encrypted client-side (${#CIPHERTEXT} bytes of ciphertext)"
|
|
|
|
# --- create ----------------------------------------------------------------
|
|
CREATE=$(curl -sS -X POST "$BASE/api/secrets" -H 'Content-Type: application/json' \
|
|
-d "{\"ciphertext\":\"$CIPHERTEXT\",\"ttl_seconds\":900}")
|
|
ID=$(printf '%s' "$CREATE" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("id",""))')
|
|
[ -n "$ID" ] || fail "create returned no id: $CREATE"
|
|
pass "created — id is ${#ID} chars"
|
|
|
|
# --- the previewer property, checked BEFORE revealing ----------------------
|
|
for _ in 1 2 3; do
|
|
code=$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/s/$ID")
|
|
[ "$code" = "200" ] || fail "GET /s/{id} returned $code"
|
|
done
|
|
pass "GET on the reveal page x3 — Slack/Outlook previews are harmless"
|
|
|
|
# --- reveal once and decrypt ----------------------------------------------
|
|
REVEAL=$(curl -sS -X POST "$BASE/api/secrets/$ID/reveal")
|
|
GOT_CT=$(printf '%s' "$REVEAL" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("ciphertext",""))')
|
|
[ -n "$GOT_CT" ] || fail "reveal returned no ciphertext: $REVEAL"
|
|
|
|
DECRYPTED=$(python3 - "$GOT_CT" "$KEY" <<'PY'
|
|
import base64, sys
|
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
u = lambda s: base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
|
blob, key = u(sys.argv[1]), u(sys.argv[2])
|
|
sys.stdout.write(AESGCM(key).decrypt(blob[:12], blob[12:], None).decode())
|
|
PY
|
|
)
|
|
[ "$DECRYPTED" = "$PLAINTEXT" ] || fail "decrypted to '$DECRYPTED', expected '$PLAINTEXT'"
|
|
pass "revealed and decrypted — round trip matches"
|
|
|
|
# --- and it is gone --------------------------------------------------------
|
|
code=$(curl -sS -o /tmp/hush-gone.$$ -w '%{http_code}' -X POST "$BASE/api/secrets/$ID/reveal")
|
|
[ "$code" = "410" ] || fail "second reveal returned $code, want 410"
|
|
GONE_BODY=$(cat /tmp/hush-gone.$$); rm -f /tmp/hush-gone.$$
|
|
pass "second reveal is 410 gone"
|
|
|
|
# --- missing and malformed are one response --------------------------------
|
|
MISSING=$(curl -sS -X POST "$BASE/api/secrets/$(python3 -c 'print("A"*43)')/reveal")
|
|
MALFORMED=$(curl -sS -X POST "$BASE/api/secrets/not-an-id/reveal")
|
|
for body in "$MISSING" "$MALFORMED"; do
|
|
norm=$(printf '%s' "$body" | python3 -c 'import json,sys; e=json.load(sys.stdin)["error"]; print(e["code"], e["message"])')
|
|
gone=$(printf '%s' "$GONE_BODY" | python3 -c 'import json,sys; e=json.load(sys.stdin)["error"]; print(e["code"], e["message"])')
|
|
[ "$norm" = "$gone" ] || fail "responses differ between causes: '$norm' vs '$gone'"
|
|
done
|
|
pass "revealed, expired, missing and malformed are one indistinguishable response"
|
|
|
|
# --- the server must refuse plaintext -------------------------------------
|
|
code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "$BASE/api/secrets" \
|
|
-H 'Content-Type: application/json' -d '{"secret":"hunter2"}')
|
|
[ "$code" != "201" ] || fail "the server ACCEPTED a plaintext field — the zero-knowledge claim is broken"
|
|
pass "a plaintext field is refused ($code)"
|
|
|
|
echo
|
|
printf '\033[32mall smoke checks passed\033[0m\n'
|