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.
305 lines
9.8 KiB
Go
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
|
|
}
|