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.
196 lines
5.7 KiB
Go
196 lines
5.7 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"sync"
|
|
)
|
|
|
|
// A minimal, dependency-free MCP server over stdio.
|
|
//
|
|
// MCP on stdio is newline-delimited JSON-RPC 2.0. The surface this server needs
|
|
// is four methods — initialize, notifications/initialized, tools/list,
|
|
// tools/call — so it is implemented directly rather than pulling in an SDK
|
|
// whose API churn would be a bigger maintenance surface than the protocol.
|
|
//
|
|
// The one rule that matters for a stdio server: stdout carries protocol frames
|
|
// ONLY. Anything diagnostic goes to stderr, because a stray Println on stdout
|
|
// corrupts the stream and the host reports an opaque parse failure.
|
|
|
|
// protocolVersion is the MCP revision this server implements. The host sends
|
|
// its own in initialize; the spec has the server answer with the version it
|
|
// will actually speak rather than echoing the client's.
|
|
const protocolVersion = "2025-06-18"
|
|
|
|
type request struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID json.RawMessage `json:"id,omitempty"`
|
|
Method string `json:"method"`
|
|
Params json.RawMessage `json:"params,omitempty"`
|
|
}
|
|
|
|
type response struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID json.RawMessage `json:"id,omitempty"`
|
|
Result any `json:"result,omitempty"`
|
|
Error *rpcError `json:"error,omitempty"`
|
|
}
|
|
|
|
type rpcError struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// JSON-RPC 2.0 reserved codes. -32602 is the one that matters here: a bad tool
|
|
// argument is an invalid-params error, not a transport failure.
|
|
const (
|
|
codeInvalidParams = -32602
|
|
codeMethodMissing = -32601
|
|
codeInternal = -32603
|
|
)
|
|
|
|
// Tool is one callable tool. Schema is the raw JSON Schema advertised to the
|
|
// host, which is what makes the arguments self-documenting in the client.
|
|
type Tool struct {
|
|
Name string
|
|
Title string
|
|
Description string
|
|
Schema map[string]any
|
|
// Handler returns the text to show the caller. An error is reported as a
|
|
// TOOL error (isError on the result) rather than a protocol error, so the
|
|
// model sees the message and can act on it instead of the call appearing
|
|
// to have failed at the transport level.
|
|
Handler func(args json.RawMessage) (string, error)
|
|
}
|
|
|
|
// Server dispatches MCP over a reader/writer pair.
|
|
type Server struct {
|
|
name string
|
|
version string
|
|
tools []Tool
|
|
|
|
mu sync.Mutex // serialises writes: one frame per line, never interleaved
|
|
out *json.Encoder
|
|
w io.Writer
|
|
}
|
|
|
|
func NewServer(name, version string, out io.Writer, tools []Tool) *Server {
|
|
return &Server{name: name, version: version, tools: tools, out: json.NewEncoder(out), w: out}
|
|
}
|
|
|
|
// Serve reads frames until stdin closes, which is how a stdio host signals
|
|
// shutdown.
|
|
func (s *Server) Serve(in io.Reader) error {
|
|
sc := bufio.NewScanner(in)
|
|
// A tool result can carry a secret link, which is small, but the buffer is
|
|
// raised so a large argument cannot truncate a frame into a parse error.
|
|
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
|
for sc.Scan() {
|
|
line := sc.Bytes()
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
var req request
|
|
if err := json.Unmarshal(line, &req); err != nil {
|
|
// Malformed frame with no id: nothing to reply to. Report on stderr
|
|
// and keep the stream alive.
|
|
fmt.Fprintf(stderr, "hush-mcp: unparseable frame: %v\n", err)
|
|
continue
|
|
}
|
|
s.dispatch(req)
|
|
}
|
|
return sc.Err()
|
|
}
|
|
|
|
func (s *Server) dispatch(req request) {
|
|
// A notification has no id and MUST NOT be answered. Replying to one is the
|
|
// classic stdio bug: the host sees an unsolicited response and desyncs.
|
|
isNotification := len(req.ID) == 0
|
|
|
|
switch req.Method {
|
|
case "initialize":
|
|
s.reply(req.ID, map[string]any{
|
|
"protocolVersion": protocolVersion,
|
|
"capabilities": map[string]any{"tools": map[string]any{}},
|
|
"serverInfo": map[string]any{"name": s.name, "version": s.version},
|
|
})
|
|
case "notifications/initialized":
|
|
// Handshake complete. Nothing to send.
|
|
case "ping":
|
|
s.reply(req.ID, map[string]any{})
|
|
case "tools/list":
|
|
list := make([]map[string]any, 0, len(s.tools))
|
|
for _, t := range s.tools {
|
|
list = append(list, map[string]any{
|
|
"name": t.Name,
|
|
"title": t.Title,
|
|
"description": t.Description,
|
|
"inputSchema": t.Schema,
|
|
})
|
|
}
|
|
s.reply(req.ID, map[string]any{"tools": list})
|
|
case "tools/call":
|
|
s.call(req)
|
|
default:
|
|
if !isNotification {
|
|
s.fail(req.ID, codeMethodMissing, "unsupported method: "+req.Method)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) call(req request) {
|
|
var p struct {
|
|
Name string `json:"name"`
|
|
Arguments json.RawMessage `json:"arguments"`
|
|
}
|
|
if err := json.Unmarshal(req.Params, &p); err != nil {
|
|
s.fail(req.ID, codeInvalidParams, "malformed tools/call params")
|
|
return
|
|
}
|
|
for _, t := range s.tools {
|
|
if t.Name != p.Name {
|
|
continue
|
|
}
|
|
text, err := t.Handler(p.Arguments)
|
|
if err != nil {
|
|
// isError:true keeps this a TOOL failure the model can read and
|
|
// react to, rather than a protocol error that looks like the server
|
|
// broke.
|
|
s.reply(req.ID, map[string]any{
|
|
"content": []map[string]any{{"type": "text", "text": err.Error()}},
|
|
"isError": true,
|
|
})
|
|
return
|
|
}
|
|
s.reply(req.ID, map[string]any{
|
|
"content": []map[string]any{{"type": "text", "text": text}},
|
|
})
|
|
return
|
|
}
|
|
s.fail(req.ID, codeInvalidParams, "unknown tool: "+p.Name)
|
|
}
|
|
|
|
func (s *Server) reply(id json.RawMessage, result any) {
|
|
if len(id) == 0 {
|
|
return
|
|
}
|
|
s.write(response{JSONRPC: "2.0", ID: id, Result: result})
|
|
}
|
|
|
|
func (s *Server) fail(id json.RawMessage, code int, msg string) {
|
|
if len(id) == 0 {
|
|
return
|
|
}
|
|
s.write(response{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}})
|
|
}
|
|
|
|
func (s *Server) write(r response) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if err := s.out.Encode(r); err != nil {
|
|
fmt.Fprintf(stderr, "hush-mcp: write failed: %v\n", err)
|
|
}
|
|
}
|