hush/scripts/smoke.sh
jx12n d7cd57f330
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
serve the MCP install instructions at /mcp
Using hush from an agent needed a clone and docs/MCP.md. It now needs one
command, and the instructions are served by the deployment itself.

`go install github.com/orchard9/hush/cmd/hush-mcp@latest` is the whole
install: cmd/hush-mcp imports only the standard library, so module graph
pruning never reaches the private go-chassis dependency cmd/hushd needs.
Verified against an empty module cache and the public proxy, then create ->
reveal end to end against production with the resulting binary.

The page carries the per-client configuration for Claude Code, Codex CLI,
Gemini CLI, VS Code, Claude Desktop, Cursor and omp. Each command was run
against the installed client rather than copied from documentation, which is
how the differences on it are there at all: VS Code's wrapper key is
`servers`, not `mcpServers`; gemini defaults to project scope, not user;
Claude Code rejects `--env` immediately before the server name.

The shared browser crypto moves from base.html into templates/crypto.html,
which the two pages that encrypt parse and this one does not. An empty
`{{define}}` cannot replace a non-empty one — text/template reads an empty
body as no definition — so the shell holds the call and the partial holds the
code, and the docs page ships no script at all.

Three things this exposed, fixed here:

- The public Ingress enumerates paths, so a handler without one 404s at the
  edge while working in `make dev`. The Ingress is now its own manifest:
  hush.yaml pins a `:bootstrap` image that does not exist, so re-applying it
  to publish a path would roll the workload onto an unpullable image.
  `make deploy-ingress` applies the route alone.
- release.sh guarded HEAD against `@{upstream}`, which is the GitHub mirror
  here, while Kaniko clones Gitea. A commit pushed to one and not the other
  would have built the previous commit silently. It now fetches and compares
  the branch that actually gets built.
- smoke.sh checks that /mcp serves the install command, so a stale rollout or
  an unexecutable template fails the release instead of being found later.
  Confirmed it fails: against production before this deploy it reported 404.
2026-09-05 14:03:34 -06:00

106 lines
5.2 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)"
# --- the agent instructions are actually served ----------------------------
# A release that rolls a stale image, or a template that fails to execute,
# shows up here rather than as a 404 someone finds later. The install command
# is the page's whole point, so that is what is checked.
MCP_PAGE=$(curl -sS -w '\n%{http_code}' "$BASE/mcp")
[ "$(printf '%s' "$MCP_PAGE" | tail -n1)" = "200" ] || fail "GET /mcp returned $(printf '%s' "$MCP_PAGE" | tail -n1)"
for want in 'go install github.com/orchard9/hush/cmd/hush-mcp' 'hush_create' 'hush_reveal'; do
printf '%s' "$MCP_PAGE" | grep -qF "$want" || fail "/mcp no longer contains '$want'"
done
pass "/mcp serves the MCP install instructions"
echo
printf '\033[32mall smoke checks passed\033[0m\n'