hush/vendor/github.com/redis/go-redis/v9/csc_commands.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

258 lines
8.4 KiB
Go

package redis
import (
"bytes"
"strconv"
"strings"
"github.com/redis/go-redis/v9/internal/proto"
)
// defaultCacheableCommands is the allow-list of read-only, deterministic
// commands whose responses may be stored in the client-side cache. Keys are
// lowercase to match baseCmd.Name() on the hot path.
var defaultCacheableCommands = map[string]struct{}{
// String commands
"get": {}, "mget": {}, "getbit": {}, "getrange": {},
"strlen": {}, "substr": {},
// Hash commands
"hget": {}, "hgetall": {}, "hmget": {},
"hkeys": {}, "hvals": {}, "hlen": {},
"hexists": {}, "hstrlen": {},
// List commands
"lindex": {}, "llen": {}, "lpos": {}, "lrange": {},
// Set commands
"scard": {}, "sismember": {}, "smembers": {}, "smismember": {},
"sdiff": {}, "sinter": {}, "sintercard": {}, "sunion": {},
// Sorted-set commands
"zcard": {}, "zcount": {}, "zlexcount": {}, "zmscore": {},
"zrange": {}, "zrangebylex": {}, "zrangebyscore": {},
"zrank": {}, "zrevrange": {}, "zrevrangebylex": {},
"zrevrangebyscore": {}, "zrevrank": {}, "zscore": {},
"zdiff": {}, "zinter": {}, "zunion": {},
// Bit commands
"bitcount": {}, "bitfield_ro": {}, "bitpos": {},
// Key/generic commands
"exists": {}, "type": {}, "sort_ro": {}, "lcs": {},
// Geo commands
"geodist": {}, "geohash": {}, "geopos": {}, "geosearch": {},
"georadiusbymember_ro": {}, "georadius_ro": {},
// Stream commands. XREAD is deliberately excluded: it supports BLOCK, and
// its $/+ IDs are state-relative, so identical args are not deterministic.
// XPENDING is excluded for the same class of reason: its extended form
// returns wall-clock-relative idle times and its IDLE filter is
// time-dependent, so identical args yield different correct results with
// no key modification (and therefore no invalidation).
"xlen": {}, "xrange": {}, "xrevrange": {},
// JSON (RedisJSON) commands
"json.get": {}, "json.mget": {}, "json.arrindex": {}, "json.arrlen": {},
"json.objkeys": {}, "json.objlen": {}, "json.resp": {},
"json.strlen": {}, "json.type": {},
// TimeSeries commands
"ts.get": {}, "ts.info": {}, "ts.range": {}, "ts.revrange": {},
}
// isCacheable reports whether cmd is eligible for client-side caching: its
// name is on the allow-list and it operates on at least one key.
func isCacheable(cmd Cmder) bool {
// Commands such as RawWriteToCmd stream replies directly to an io.Writer.
// Capturing their replies for CSC would buffer the entire response first,
// defeating their streaming and allocation guarantees.
if cmd.NoRetry() {
return false
}
if _, ok := defaultCacheableCommands[cmd.Name()]; !ok {
return false
}
// SORT_RO ... BY/GET reads pattern keys that extractRedisKeys can't
// enumerate, so its invalidations would be dropped and the result go stale.
// Plain SORT_RO is fine.
if cmd.Name() == "sort_ro" && sortROHasByGet(cmd) {
return false
}
return cmdFirstKeyPosWithInfo(cmd, nil) != 0
}
// sortROHasByGet reports whether a SORT_RO invocation uses BY or GET
// (case-insensitive), scanning past the command name and key. stringArg
// normalizes string, *string, and []byte tokens.
func sortROHasByGet(cmd Cmder) bool {
for i := 2; i < len(cmd.Args()); i++ {
if s := cmd.stringArg(i); strings.EqualFold(s, "by") || strings.EqualFold(s, "get") {
return true
}
}
return false
}
// isClientTrackingCmd reports whether cmd is a CLIENT TRACKING subcommand (any
// mode: ON, OFF, or with options). Name and stringArg normalize string,
// *string, and []byte arguments.
func isClientTrackingCmd(cmd Cmder) bool {
return cmd.Name() == "client" && strings.EqualFold(cmd.stringArg(1), "tracking")
}
// isSelectCmd reports whether cmd changes the selected database on its
// connection. CSC keys are namespaced with Options.DB, so a runtime SELECT
// would make the connection's actual database diverge from the cache namespace.
func isSelectCmd(cmd Cmder) bool {
return cmd.Name() == "select"
}
// isAuthCmd reports whether cmd changes the authenticated user on its
// connection. The cache namespace is fixed from Options.Username, so runtime
// authentication would make the connection identity diverge from it.
func isAuthCmd(cmd Cmder) bool {
return cmd.Name() == "auth"
}
// isProtocolChangingHelloCmd reports whether HELLO includes a protocol version
// (and can therefore switch a tracked RESP3 connection to RESP2). A bare HELLO
// only reports connection properties and is safe.
func isProtocolChangingHelloCmd(cmd Cmder) bool {
return cmd.Name() == "hello" && len(cmd.Args()) > 1
}
// isResetCmd reports whether cmd resets all server-side connection state.
// RESET disables tracking, switches to RESP2, deauthenticates, and changes
// other state that a pooled CSC connection relies on.
func isResetCmd(cmd Cmder) bool {
return cmd.Name() == "reset"
}
// isSubscribeCmd reports whether a raw command would turn an ordinary pooled
// connection into a Pub/Sub connection. Pub/Sub pushes are deliberately left
// for the dedicated PubSub reader, so the CSC drainer cannot safely own such a
// connection.
func isSubscribeCmd(cmd Cmder) bool {
switch cmd.Name() {
case "subscribe", "psubscribe", "ssubscribe":
return true
default:
return false
}
}
// buildCacheKey returns the RESP-encoded form of the command's argument list,
// used as a collision-free canonical cache key. ok is false when the writer
// cannot marshal the arguments, in which case the caller must skip caching
// rather than bucket the command under an empty key.
func buildCacheKey(cmd Cmder) (string, bool) {
args := cmd.Args()
if len(args) == 0 {
return "", false
}
var buf bytes.Buffer
if err := proto.NewWriter(&buf).WriteArgs(args); err != nil {
return "", false
}
return buf.String(), true
}
// keyArg renders the key argument at pos exactly as proto.Writer sends it to
// the server, so invalidation lookups match the key names in the server's
// "invalidate" pushes. Only types whose stringArg rendering is byte-identical
// to the wire encoding are accepted (fmt.Sprint of any integer matches the
// writer's base-10 strconv output); for anything else — pointers, bools,
// times, durations, floats, BinaryMarshaler values — the rendering can
// diverge, the invalidation would never match, and the entry would be served
// stale forever, so ok=false and the caller skips caching (see processCached).
func keyArg(cmd Cmder, pos int) (string, bool) {
args := cmd.Args()
if pos < 0 || pos >= len(args) {
return "", false
}
switch args[pos].(type) {
case string, []byte,
int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64:
return cmd.stringArg(pos), true
}
return "", false
}
// extractRedisKeys returns the Redis key arguments from cmd. The result lets
// the cache map incoming invalidations back to affected entries. Returns nil
// (caller skips caching) when any key
// argument cannot be rendered in its wire form (see keyArg).
func extractRedisKeys(cmd Cmder) []string {
firstKey := cmdFirstKeyPosWithInfo(cmd, nil)
if firstKey == 0 {
return nil
}
argsLen := len(cmd.Args())
if firstKey >= argsLen {
return nil
}
switch cmd.Name() {
// All remaining args from firstKeyPos are keys.
case "mget", "exists", "sdiff", "sinter", "sunion":
keys := make([]string, 0, argsLen-firstKey)
for i := firstKey; i < argsLen; i++ {
k, ok := keyArg(cmd, i)
if !ok {
return nil
}
keys = append(keys, k)
}
return keys
// Numkeys pattern: numkeys at args[1], keys from args[2].
case "sintercard", "zdiff", "zinter", "zunion":
if argsLen < 3 {
return nil
}
numKeys, err := strconv.Atoi(cmd.stringArg(1))
if err != nil || numKeys <= 0 {
return nil
}
keys := make([]string, 0, numKeys)
for i := 2; i < 2+numKeys && i < argsLen; i++ {
k, ok := keyArg(cmd, i)
if !ok {
return nil
}
keys = append(keys, k)
}
return keys
// LCS: exactly two consecutive keys starting at firstKeyPos.
case "lcs":
if firstKey+1 >= argsLen {
return nil
}
k1, ok1 := keyArg(cmd, firstKey)
k2, ok2 := keyArg(cmd, firstKey+1)
if !ok1 || !ok2 {
return nil
}
return []string{k1, k2}
// JSON.MGET: keys from firstKeyPos to second-to-last (last arg is the
// JSON path, not a key).
case "json.mget":
lastKey := argsLen - 2
if lastKey < firstKey {
return nil
}
keys := make([]string, 0, lastKey-firstKey+1)
for i := firstKey; i <= lastKey; i++ {
k, ok := keyArg(cmd, i)
if !ok {
return nil
}
keys = append(keys, k)
}
return keys
}
// Single key at firstKeyPos (GET, HGET, LRANGE, ...).
k, ok := keyArg(cmd, firstKey)
if !ok {
return nil
}
return []string{k}
}