README
¶
Hush 🔇
Stealth-first API protocol for Go.
Hush is a network protocol framework that makes your API invisible to standard tooling. No HTTP endpoints to discover, no readable request structure, no replay tooling. No HTTP endpoints to discover, no readable request structure, no replay attacks. Supports both QUIC (UDP, fast) and TLS-over-TCP (compatible with Cloudflare and standard load balancers), encodes payloads in a compact binary TLV format, and encrypts every frame with per-session AES-256-GCM keys.
import "github.com/feralbureau/hush-go"
There is also a Rust implementation (hush-rs) with same wire format, same crypto, same semantics and fully interoperable.
Why Hush
| REST problem | Hush fix |
|---|---|
| Anybody can open DevTools and replicate requests | Custom ALPN hush/1 — HTTP tools can't connect |
| API surface is fully observable | Binary TLV + AEAD — no readable structure |
| Trivial to fuzz and pentest | Session-bound encryption + sequence numbers |
| Unofficial clients are easy to write | Per-session ephemeral ECDH keys make replay useless |
| gRPC is bloated and painful | TLV instead of protobuf, no codegen, no schema files |
You can disable any of these protections when you don't need them (see Configuration).
Quick Start
Prerequisites
- Go 1.26+
- Clone the repo and generate a test TLS certificate:
git clone https://github.com/feralbureau/hush-go.git
cd hush-go
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-keyout test-key.pem -out test-cert.pem -days 3650 -nodes \
-subj "/CN=hush.test" -addext "subjectAltName=DNS:hush.test,IP:127.0.0.1"
Run an example
Each directory under examples/ contains a complete server and
client in a single main.go. Start the server, then run the client using the
key, secret, and port it prints.
# Terminal 1 — start the server
go run examples/weather/main.go server
# Terminal 2 — query weather
go run examples/weather/main.go client <key_id> <key_secret_hex> <hush_port> London
Minimal API (without examples)
If you just want to write a server from scratch:
package main
import (
"context"
"crypto/tls"
"log"
"net"
"os/signal"
"github.com/feralbureau/hush-go/frame"
"github.com/feralbureau/hush-go/server"
"github.com/feralbureau/hush-go/session"
"github.com/feralbureau/hush-go/tlv"
)
func main() {
apiKey, _ := session.GenerateAPIKey()
keyStore := session.MapKeyStore{apiKey.ID: apiKey.Secret}
cert, _ := tls.LoadX509KeyPair("test-cert.pem", "test-key.pem")
tlsCfg := &tls.Config{Certificates: []tls.Certificate{cert}}
srv, _ := server.NewServer(keyStore, server.WithTLSConfig(tlsCfg))
srv.HandleFunc(0x0001, func(ctx context.Context, r *server.Request) (*frame.Response, error) {
name, _ := r.Payload.GetString("name")
return server.NewResponse(tlv.NewMap().
Set("greeting", tlv.String("hello, "+name))), nil
})
// Bind to a random port so the OS chooses one
addr, _ := net.ResolveUDPAddr("udp", "127.0.0.1:0")
conn, _ := net.ListenUDP("udp", addr)
log.Printf("listening on port %d", conn.LocalAddr().(*net.UDPAddr).Port)
ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt)
srv.ListenAndServeOnConn(ctx, conn)
Or over TCP (behind Cloudflare):
```go
srv.ListenAndServeTCP(ctx, ":8443")
}
And the matching client:
```go
package main
import (
"context"
"crypto/tls"
"fmt"
"log"
"github.com/feralbureau/hush-go/client"
"github.com/feralbureau/hush-go/session"
"github.com/feralbureau/hush-go/tlv"
)
func main() {
key := &session.APIKey{ID: "<id>", Secret: []byte("<secret>")}
tlsConf := &tls.Config{InsecureSkipVerify: true, ServerName: "hush.test"}
c, err := client.Dial(context.Background(), "127.0.0.1:<port>", key,
client.WithTLSConfig(tlsConf))
if err != nil {
log.Fatal(err)
}
defer c.Close()
resp, _ := c.Do(context.Background(), 0x0001,
tlv.NewMap().Set("name", tlv.String("world")))
greeting, _ := resp.Payload.GetString("greeting")
fmt.Println(greeting)
}
Package Overview
hush-go/
├── transport/ QUIC (hush://) and TCP (tcps://) dial/listen with ALPN config
├── session/ X25519 key exchange, AES-256-GCM, session store
├── frame/ Length-prefixed encrypted/plaintext wire frames
├── tlv/ Binary TLV serialization (string, ints, floats, maps, arrays)
├── client/ High-level client (connect, send, receive)
├── server/ High-level server (TLS, sessions, handler dispatch, streaming,
│ │ middleware, rate limiting)
│ ├── middleware.go Composable middleware chain
│ ├── ratelimit.go Sliding-window rate limiters
│ └── stream.go Built-in event pub/sub hub
│ └── stream.go Built-in event pub/sub hub
└── media/ Session-bound media tokens for HTTP media delivery
transport — QUIC and TCP connectivity
// Override the ALPN for the protocol (default: "hush/1")
transport.DefaultALPN = "my-app/1"
conn, _ := transport.Dial(ctx, "127.0.0.1:443", tlsCfg)
tcpConn, _ := transport.DialTCP(ctx, "127.0.0.1:8443", tlsCfg)
listener, _ := transport.ListenTCP(":8443", tlsCfg)
listener, _ := transport.Listen(":443", tlsCfg)
The TLS config passed to Dial/Listen acts as the source for certificates and TCP helpers use the same TLS config and ALPN, making it easy to switch.
session — Key exchange, crypto, configuration
POST-QUIC HANDSHAKE:
Client ──► api_key_id + X25519_pub ──► Server
Client ◄── X25519_pub + session_id ◄── Server
Both: shared = ECDH(priv, peer_pub)
key = HKDF-SHA256(salt=shared, ikm=api_key_secret, info="hush-v1-key")
// Generate API keys
key, _ := session.GenerateAPIKey()
// Low-level handshake
priv, _ := session.GenerateKeyPair()
sess, _ := session.NegotiateClient(ctx, stream, key, priv)
// Key store interface
type APIKeyStore interface {
Get(id string) []byte
}
store := session.MapKeyStore{key.ID: key.Secret}
// Session store with configurable timeouts
store := session.NewSessionStore(session.SessionConfig{
IdleTimeout: 5 * time.Minute,
MaxLifetime: 24 * time.Hour,
GCInterval: 1 * time.Minute,
})
frame — Wire format
Every request/response is a single QUIC stream containing one frame:
4 bytes: frame_length (big-endian)
4 bytes: sequence_number (big-endian)
N bytes: frame_data
When encrypted (key != nil):
frame_data = nonce (12) || AES-256-GCM ciphertext || tag (16)
When plaintext (key == nil):
frame_data = raw plaintext bytes
// Encrypted (default)
frame.WriteRequest(stream, key, seq, req)
req, seq, _ := frame.ReadRequest(stream, key)
// Plaintext (no encryption)
frame.WriteRequest(stream, nil, seq, req)
req, seq, _ := frame.ReadRequest(stream, nil)
Allowed opcode ranges
Opcodes are uint16. The convention is:
| Range | Use |
|---|---|
0x0000 |
Reserved (server push events) |
0x0001–0x00FF |
System |
0x0100–0x7FFF |
Application |
0x8000–0xFFFF |
Reserved for future Hush extensions |
tlv — Binary payload serialization
Compact, no schema files, no codegen. The wire format is:
type (1 byte) || length (LEB128 varint) || value (length bytes)
Supported types:
| Type | Go constructor | Go accessor |
|---|---|---|
| String | tlv.String(s) |
v.String() |
| Bytes | tlv.Bytes(b) |
v.Bytes() |
| Uint8 | tlv.Uint8(n) |
v.Uint8() |
| Uint16 | tlv.Uint16(n) |
v.Uint16() |
| Uint32 | tlv.Uint32(n) |
v.Uint32() |
| Uint64 | tlv.Uint64(n) |
v.Uint64() |
| Int32 | tlv.Int32(n) |
v.Int32() |
| Int64 | tlv.Int64(n) |
v.Int64() |
| Float32 | tlv.Float32(f) |
v.Float32() |
| Float64 | tlv.Float64(f) |
v.Float64() |
| Bool | tlv.Bool(b) |
v.Bool() |
| Array | tlv.Array(vals) |
v.Array() |
| Map | tlv.NewMap().Set(...) |
v.Map() |
| Timestamp | tlv.Timestamp(t) |
v.Timestamp() |
| Null | tlv.Null |
— |
Maps — the primary payload structure:
payload := tlv.NewMap().
Set("name", tlv.String("alice")).
Set("count", tlv.Uint64(42)).
Set("nested", tlv.MapValue(tlv.NewMap().
Set("key", tlv.Bool(true)),
)).
// Reading
name, _ := payload.GetString("name")
count, _ := payload.GetUint64("count")
nested, _ := payload.GetMap("nested")
session — Anonymous handshake
The handshake supports anonymous sessions by sending key_len=0.
The session key is derived from X25519 ECDH alone (no API secret).
This allows unauthenticated bootstrap flows (e.g., login) over an encrypted channel.
// Client: nil or empty ID = anonymous
client, _ := client.Dial(ctx, "127.0.0.1:443", &session.APIKey{ID: ""})
// Server: accepts both anonymous and authenticated
srv.HandleFunc(0x0001, func(ctx context.Context, req *Request) (*frame.Response, error) {
// Login handler — accessible without API key
return server.NewResponse(tlv.NewMap().Set("status", tlv.String("ok"))), nil
})
client — High-level client
c, err := client.Dial(ctx, addr, apiKey, opts...)
resp, err := c.Do(ctx, opcode, payload)
sid := c.SessionID()
c.Close()
server — High-level server
srv, _ := server.NewServer(keyStore, opts...)
// Standard request-response handler
srv.HandleFunc(0x0001, func(ctx context.Context, r *server.Request) (*frame.Response, error) {
return server.NewResponse(tlv.NewMap().Set("ok", tlv.Bool(true))), nil
})
// Streaming handler (full stream control, e.g. event subscriptions)
srv.HandleStreamFunc(0x0002, func(ctx context.Context, r *server.Request,
stream io.ReadWriteCloser, key []byte) error {
// write frames to stream
return nil
})
srv.ListenAndServe(ctx, ":443")
// Or use an existing UDP socket
conn, _ := net.ListenUDP("udp", addr)
srv.ListenAndServeOnConn(ctx, conn)
Server options:
| Option | Purpose |
|---|---|
WithTLSConfig(cfg) |
TLS certificates (required) |
WithLogger(l) |
Structured logger (nil = silent) |
WithSessionConfig(cfg) |
Session timeouts |
WithMediaSupport(baseURL) |
Media token store |
server — Middleware and rate limiting
limiter := server.NewSlidingWindowLimiter(100, time.Minute)
srv.Use(server.RateLimitBySession(limiter))
srv.Use(server.LoggingMiddleware(srv))
Built-in middleware:
LoggingMiddleware— logs opcode, status, durationRecoveryMiddleware— catches panics in handlersRateLimitBySession— per-session sliding windowRateLimitByAPIKey— per-key sliding windowRateLimitByRemoteAddr— per-IP sliding windowMultiRateLimitMiddleware— check multiple limitersContextMiddleware— inject values into contextRequireAPIKeyID— restrict to specific API keys
media — Media token management
For serving large files (images, audio, HLS streams) over HTTPS, Hush uses session-bound media tokens. The QUIC session handles API calls; a companion HTTPS server handles media delivery.
store := media.NewTokenStore(func(sid uint64) bool {
_, ok := sessionStore.Get(sid)
return ok
})
// Issue a token bound to a session
tok, _ := store.Issue(sessionID, "track-abc")
// Validate and extend (for initial access)
valid := store.Validate(tok.ID)
// Lightweight existence check (for HLS segment proxying)
exists := store.Exists(tok.ID)
// Absolute TTL (configurable)
store.MaxTokenTTL = 30 * time.Minute
// Build media URLs
builder := media.NewMediaURLBuilder("https://media.example.com", store)
url := builder.BuildURL(tok.ID, "track-abc")
// → "https://media.example.com/media/ab12.../track-abc"
For serving large files (images, audio, HLS streams) over HTTPS, Hush uses session-bound media tokens. The QUIC session handles API calls; a companion HTTPS server handles media delivery.
store := media.NewTokenStore(func(sid uint64) bool {
_, ok := sessionStore.Get(sid)
return ok
})
// Issue a token bound to a session
tok, _ := store.Issue(sessionID, "track-abc")
// Validate and extend (for initial access)
valid := store.Validate(tok.ID)
// Lightweight existence check (for HLS segment proxying)
exists := store.Exists(tok.ID)
// Absolute TTL (configurable)
store.MaxTokenTTL = 30 * time.Minute
// Build media URLs
builder := media.NewMediaURLBuilder("https://media.example.com", store)
url := builder.BuildURL(tok.ID, "track-abc")
// → "https://media.example.com/media/ab12.../track-abc"
Examples
| Example | Description | Run it |
|---|---|---|
| Weather | Calls wttr.in through Hush. External HTTP from a handler. | go run examples/weather/main.go server → client <key> <secret> <port> London |
| CRUD Notes | In-memory notes — create, list, get, update, delete. Multiple opcodes. | go run examples/crud/main.go server → client <key> <secret> <port> |
| Chat | Real-time chat room using the event hub. Pub/sub streaming. | go run examples/chat/main.go server → client <key> <secret> <port> Alice |
All three follow the same pattern:
# Terminal 1 — start the server
go run examples/weather/main.go server
# Terminal 2 — use the key, secret, and port it prints
go run examples/weather/main.go client <key_id> <key_secret_hex> <hush_port> London
Event Streaming
Hush includes a built-in in-memory topic-based pub/sub hub.
hub := server.NewHub()
// Register handlers
srv.HandleFunc(0x0401, hub.PublishHandler())
srv.HandleStreamFunc(0x0402, hub.SubscribeHandler())
srv.HandleFunc(0x0403, hub.ListTopicsHandler())
// Publish from anywhere
hub.Publish("alerts", tlv.NewMap().Set("level", tlv.String("info")))
Client side
// Subscribe to a topic (streaming — stays open)
// Subscribe creates a long-lived stream that pushes events as they arrive.
// The client receives frames with status=0 and the event payload.
// Publish an event (standard request-response)
resp, _ := c.Do(ctx, 0x0401, tlv.NewMap().
Set("topic", tlv.String("alerts")).
Set("payload", tlv.MapValue(tlv.NewMap().
Set("level", tlv.String("info")),
)),
)
// List active topics
resp, _ := c.Do(ctx, 0x0403, nil)
Configuration
Everything in Hush is configurable. Here's every tuning point:
Encryption on/off
Pass nil instead of a session key to read/write plaintext frames:
frame.WriteRequest(stream, nil, seq, req) // no encryption
req, seq, _ := frame.ReadRequest(stream, nil) // no decryption
Session timeouts
srv, _ := server.NewServer(ks,
server.WithSessionConfig(session.SessionConfig{
IdleTimeout: 10 * time.Minute, // default: 5m
MaxLifetime: 48 * time.Hour, // default: 24h
GCInterval: 30 * time.Second, // default: 1m
}),
)
ALPN
import "github.com/feralbureau/hush-go/transport"
transport.DefaultALPN = "my-custom-proto/1"
Media token TTL
store.MaxTokenTTL = 10 * time.Minute // default: 2h
Logger
// Colored terminal output (stderr)
srv, _ := server.NewServer(ks,
server.WithLogger(server.NewLogger("hush")),
)
// Standard log package (no colors)
srv, _ := server.NewServer(ks,
server.WithLogger(log.New(os.Stdout, "hush: ", log.Ltime|log.Lmsgprefix)),
)
Log level format
Server logs use level prefixes: [INF], [WRN], [ERR].
Wire Protocol Reference
Frame format
frame_length (uint32 BE) || frame_data
Encrypted frame_data:
sequence_number (uint32 BE) || nonce (12 bytes) || ciphertext || AEAD tag (16 bytes)
Plaintext frame_data:
sequence_number (uint32 BE) || plaintext
Request plaintext
opcode (uint16 BE) || tlv_payload (optional)
Response plaintext
status_code (uint8) || tlv_payload (optional)
Status codes
| Code | Name |
|---|---|
0x00 |
Success |
0x01 |
Bad request |
0x02 |
Unauthenticated |
0x03 |
Permission denied |
0x04 |
Not found |
0x05 |
Session expired |
0x06 |
Rate limited |
0x07 |
Internal error |
0x80+ |
Application-defined |
Session handshake
Client → Server: api_key_id_len (uint16 BE) || api_key_id || X25519_pubkey (32 bytes)
Server → Client: X25519_pubkey (32 bytes) || session_id (uint64 BE)
Shared secret = ECDH(client_priv, server_pub)
Session key = HKDF-SHA256(ikm=api_key_secret, salt=shared_secret, info="hush-v1-key")
Security Model
| Threat | Mitigation |
|---|---|
| Eavesdropping | TLS 1.3 + AES-256-GCM per frame |
| Replay attacks | Per-frame sequence number, per-session keys |
| API key theft | Keys are PSK for ECDH — never sent after handshake |
| Observability | Custom ALPN, binary wire format, no readable structure |
| Fuzzing | Invalid frames fail AEAD decryption at the transport layer |
| Session hijack | Session ID is tied to ECDH-derived key |
Tradeoffs
- Browser support: Hush uses raw QUIC — browsers can't open WebSocket-style connections to it. For web clients, run an HTTPS or WebSocket bridge.
- Complexity: QUIC + custom crypto is heavier than plain HTTP. You're trading simplicity for stealth.
- Debugging: No curl, no Postman, no DevTools. Use the included client, or
run in plaintext mode (
key == nil) during development.
Project Structure
hush-go/
├── transport/ QUIC (hush://) and TCP (tcps://) dial/listen
├── session/ X25519 key exchange, AES-256-GCM, session store, config
├── frame/ Length-prefixed encrypted/plaintext wire frames
├── tlv/ Binary TLV encode/decode, all types
├── client/ High-level client
├── server/ High-level server, handlers, event streaming hub
├── media/ Session-bound media token store
├── test-cert.pem TLS cert for local testing
└── test-key.pem TLS key for local testing
Contributing
Contributions are welcome. Before opening a pull request, please read the CONTRIBUTING.md.
License
Directories
¶
| Path | Synopsis |
|---|---|
|
Package client implements the Hush protocol client.
|
Package client implements the Hush protocol client. |
|
examples
|
|
|
chat
command
Hush example: Real-time chat.
|
Hush example: Real-time chat. |
|
crud
command
Hush example: CRUD notes.
|
Hush example: CRUD notes. |
|
weather
command
Hush example: Weather API.
|
Hush example: Weather API. |
|
Package frame implements the Hush encrypted frame wire format.
|
Package frame implements the Hush encrypted frame wire format. |
|
Package media provides session-bound media token management for Hush.
|
Package media provides session-bound media token management for Hush. |
|
Package server provides a simple colored logger for Hush.
|
Package server provides a simple colored logger for Hush. |
|
Package session implements Hush session key exchange, authentication, and session lifecycle management.
|
Package session implements Hush session key exchange, authentication, and session lifecycle management. |
|
Package tlv implements the Hush Type-Length-Value wire format.
|
Package tlv implements the Hush Type-Length-Value wire format. |
|
Package transport provides QUIC connection helpers for Hush.
|
Package transport provides QUIC connection helpers for Hush. |