hush-go

module
v0.0.0-...-18e49d7 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 25, 2026 License: MIT

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)
0x00010x00FF System
0x01000x7FFF Application
0x80000xFFFF 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, duration
  • RecoveryMiddleware — catches panics in handlers
  • RateLimitBySession — per-session sliding window
  • RateLimitByAPIKey — per-key sliding window
  • RateLimitByRemoteAddr — per-IP sliding window
  • MultiRateLimitMiddleware — check multiple limiters
  • ContextMiddleware — inject values into context
  • RequireAPIKeyID — 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 serverclient <key> <secret> <port> London
CRUD Notes In-memory notes — create, list, get, update, delete. Multiple opcodes. go run examples/crud/main.go serverclient <key> <secret> <port>
Chat Real-time chat room using the event hub. Pub/sub streaming. go run examples/chat/main.go serverclient <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

MIT

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL