hush/cmd/hush-mcp/main.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

305 lines
9.8 KiB
Go

// Command hush-mcp exposes hush to an MCP host (omp, Claude Code, any client)
// as two tools: hush_create and hush_reveal.
//
// It runs LOCALLY, on the operator's machine, and does the AES-256-GCM itself.
// That is the whole reason it is a stdio binary rather than an endpoint served
// by hushd: if the server did the encrypting, the server could read every
// secret created through MCP, and hush's central claim would hold for browser
// users and quietly not hold for agent users. Two guarantees behind one URL is
// worse than one honest guarantee.
//
// So this binary is a peer of the browser, not a peer of the server: it mints
// the key, encrypts, posts ciphertext, and assembles the `#fragment` link.
// hushd sees exactly what it sees from a browser.
package main
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const version = "0.1.0"
// stderr is indirected so tests can capture diagnostics. stdout is reserved
// for protocol frames — a stray write there corrupts the stream.
var stderr io.Writer = os.Stderr
// b64 matches the server and the browser: base64url, unpadded. One spelling of
// the wire format across all three implementations.
var b64 = base64.RawURLEncoding
func main() {
base := strings.TrimSuffix(os.Getenv("HUSH_BASE_URL"), "/")
if base == "" {
base = "https://hush.threesix.ai"
}
c := &client{
base: base,
http: &http.Client{Timeout: 15 * time.Second},
// A create token is only needed if the deployment has closed anonymous
// create (HUSH_REQUIRE_AUTH). Empty is the normal case.
token: os.Getenv("HUSH_CREATE_TOKEN"),
}
srv := NewServer("hush", version, os.Stdout, tools(c))
if err := srv.Serve(os.Stdin); err != nil {
fmt.Fprintf(stderr, "hush-mcp: %v\n", err)
os.Exit(1)
}
}
func tools(c *client) []Tool {
return []Tool{
{
Name: "hush_create",
Title: "Create a one-time secret link",
Description: "Encrypt a secret locally and store the ciphertext on hush, returning a " +
"link that works exactly once. The encryption key is generated on this machine and " +
"travels only in the link's #fragment, so the hush server never receives it and " +
"cannot read the secret. Use this to hand a credential to someone instead of " +
"pasting it into chat or email. The returned link is shown once and cannot be " +
"recovered — pass it on immediately.",
Schema: map[string]any{
"type": "object",
"properties": map[string]any{
"secret": map[string]any{
"type": "string",
"description": "The plaintext to send. Never leaves this machine unencrypted.",
},
"ttl_seconds": map[string]any{
"type": "integer",
"description": "Lifetime in seconds, 300 to 604800. Defaults to 86400 (24h).",
"minimum": 300,
"maximum": 604800,
},
},
"required": []string{"secret"},
"additionalProperties": false,
},
Handler: c.create,
},
{
Name: "hush_reveal",
Title: "Open a one-time secret link",
Description: "Fetch and decrypt a hush link, DESTROYING it in the process. This is " +
"irreversible: after this call the link is dead and nobody else can open it, " +
"including the intended recipient. Only call this on a link meant for you.",
Schema: map[string]any{
"type": "object",
"properties": map[string]any{
"link": map[string]any{
"type": "string",
"description": "The full hush link including the #fragment key.",
},
},
"required": []string{"link"},
"additionalProperties": false,
},
Handler: c.reveal,
},
}
}
type client struct {
base string
http *http.Client
token string
}
func (c *client) create(raw json.RawMessage) (string, error) {
var args struct {
Secret string `json:"secret"`
TTLSeconds int64 `json:"ttl_seconds"`
}
if err := json.Unmarshal(raw, &args); err != nil {
return "", fmt.Errorf("bad arguments: %w", err)
}
if args.Secret == "" {
return "", errors.New("secret is empty — nothing to send")
}
ciphertext, key, err := seal(args.Secret)
if err != nil {
return "", fmt.Errorf("encrypt locally: %w", err)
}
body, _ := json.Marshal(map[string]any{"ciphertext": ciphertext, "ttl_seconds": args.TTLSeconds})
req, err := http.NewRequest(http.MethodPost, c.base+"/api/secrets", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
res, err := c.http.Do(req)
if err != nil {
return "", fmt.Errorf("reach hush at %s: %w", c.base, err)
}
defer res.Body.Close()
payload, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if res.StatusCode != http.StatusCreated {
return "", fmt.Errorf("hush refused the secret (%s): %s", res.Status, apiMessage(payload))
}
var out struct {
ID string `json:"id"`
ExpiresAt string `json:"expires_at"`
TTLSeconds int64 `json:"ttl_seconds"`
}
if err := json.Unmarshal(payload, &out); err != nil || out.ID == "" {
return "", fmt.Errorf("unexpected response from hush: %s", string(payload))
}
// The key is appended HERE, on this machine. It was never in the request.
link := c.base + "/s/" + out.ID + "#" + key
return fmt.Sprintf(
"%s\n\nOne-time link — works exactly once, expires %s.\n"+
"The key is in the #fragment, so hush cannot read the secret.\n"+
"This link cannot be shown again: send it now.",
link, out.ExpiresAt), nil
}
func (c *client) reveal(raw json.RawMessage) (string, error) {
var args struct {
Link string `json:"link"`
}
if err := json.Unmarshal(raw, &args); err != nil {
return "", fmt.Errorf("bad arguments: %w", err)
}
u, err := url.Parse(strings.TrimSpace(args.Link))
if err != nil {
return "", fmt.Errorf("not a URL: %w", err)
}
if u.Fragment == "" {
// The commonest real failure: a chat client or mail rewriter dropped
// the fragment. Say so precisely, because the secret is still intact
// and the fix is to ask the sender for the whole link.
return "", errors.New("this link has no #fragment, so it carries no key. " +
"Chat and email clients sometimes truncate it — ask the sender for the full link. " +
"The secret has NOT been opened.")
}
id := strings.TrimPrefix(u.Path, "/s/")
if id == "" || strings.Contains(id, "/") {
return "", fmt.Errorf("cannot find a secret id in the path %q", u.Path)
}
// Reveal against the link's OWN origin, not the configured base: a link
// from a different hush deployment must not be posted to this one, where
// the id would be meaningless.
origin := u.Scheme + "://" + u.Host
res, err := c.http.Post(origin+"/api/secrets/"+url.PathEscape(id)+"/reveal", "application/json", nil)
if err != nil {
return "", fmt.Errorf("reach hush at %s: %w", origin, err)
}
defer res.Body.Close()
payload, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if res.StatusCode == http.StatusGone {
return "", errors.New("gone: this link was already opened, expired, or never existed. " +
"If you did not open it yourself, assume someone else did and ask the sender to rotate the secret.")
}
if res.StatusCode != http.StatusOK {
return "", fmt.Errorf("hush returned %s: %s", res.Status, apiMessage(payload))
}
var out struct {
Ciphertext string `json:"ciphertext"`
}
if err := json.Unmarshal(payload, &out); err != nil {
return "", fmt.Errorf("unexpected response from hush: %s", string(payload))
}
plain, err := open(out.Ciphertext, u.Fragment)
if err != nil {
// The ciphertext is already destroyed at this point, so there is
// nothing to retry. Say that plainly.
return "", fmt.Errorf("the key in this link does not open this secret, and the ciphertext "+
"has now been destroyed (%w). The link was probably altered in transit; ask for a new one", err)
}
return plain, nil
}
// seal encrypts with AES-256-GCM and returns (ciphertext, key), both base64url.
// The nonce is prepended to the ciphertext so the stored blob is self-contained
// — byte-for-byte the format templates/base.html produces.
func seal(plaintext string) (string, string, error) {
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return "", "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", "", err
}
blob := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return b64.EncodeToString(blob), b64.EncodeToString(key), nil
}
func open(ciphertext, keyStr string) (string, error) {
blob, err := b64.DecodeString(ciphertext)
if err != nil {
return "", fmt.Errorf("ciphertext is not base64url: %w", err)
}
key, err := b64.DecodeString(keyStr)
if err != nil {
return "", fmt.Errorf("key is not base64url: %w", err)
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(blob) < gcm.NonceSize() {
return "", errors.New("ciphertext is too short to contain a nonce")
}
plain, err := gcm.Open(nil, blob[:gcm.NonceSize()], blob[gcm.NonceSize():], nil)
if err != nil {
return "", errors.New("authentication failed")
}
return string(plain), nil
}
// apiMessage pulls the human message out of hush's error envelope, falling back
// to the raw body so a proxy's HTML error page is still readable.
func apiMessage(payload []byte) string {
var e struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(payload, &e); err == nil && e.Error.Message != "" {
return e.Error.Code + ": " + e.Error.Message
}
s := strings.TrimSpace(string(payload))
if len(s) > 300 {
s = s[:300] + "…"
}
return s
}