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.
206 lines
5.9 KiB
Go
206 lines
5.9 KiB
Go
package redis
|
|
|
|
import "context"
|
|
|
|
type ClusterCmdable interface {
|
|
ClusterMyShardID(ctx context.Context) *StringCmd
|
|
ClusterMyID(ctx context.Context) *StringCmd
|
|
ClusterSlots(ctx context.Context) *ClusterSlotsCmd
|
|
ClusterShards(ctx context.Context) *ClusterShardsCmd
|
|
ClusterLinks(ctx context.Context) *ClusterLinksCmd
|
|
ClusterNodes(ctx context.Context) *StringCmd
|
|
ClusterMeet(ctx context.Context, host, port string) *StatusCmd
|
|
ClusterForget(ctx context.Context, nodeID string) *StatusCmd
|
|
ClusterReplicate(ctx context.Context, nodeID string) *StatusCmd
|
|
ClusterResetSoft(ctx context.Context) *StatusCmd
|
|
ClusterResetHard(ctx context.Context) *StatusCmd
|
|
ClusterInfo(ctx context.Context) *StringCmd
|
|
ClusterKeySlot(ctx context.Context, key string) *IntCmd
|
|
ClusterGetKeysInSlot(ctx context.Context, slot int, count int) *StringSliceCmd
|
|
ClusterCountFailureReports(ctx context.Context, nodeID string) *IntCmd
|
|
ClusterCountKeysInSlot(ctx context.Context, slot int) *IntCmd
|
|
ClusterDelSlots(ctx context.Context, slots ...int) *StatusCmd
|
|
ClusterDelSlotsRange(ctx context.Context, min, max int) *StatusCmd
|
|
ClusterSaveConfig(ctx context.Context) *StatusCmd
|
|
ClusterSlaves(ctx context.Context, nodeID string) *StringSliceCmd
|
|
ClusterFailover(ctx context.Context) *StatusCmd
|
|
ClusterAddSlots(ctx context.Context, slots ...int) *StatusCmd
|
|
ClusterAddSlotsRange(ctx context.Context, min, max int) *StatusCmd
|
|
ReadOnly(ctx context.Context) *StatusCmd
|
|
ReadWrite(ctx context.Context) *StatusCmd
|
|
}
|
|
|
|
func (c cmdable) ClusterMyShardID(ctx context.Context) *StringCmd {
|
|
cmd := NewStringCmd(ctx, "cluster", "myshardid")
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
func (c cmdable) ClusterMyID(ctx context.Context) *StringCmd {
|
|
cmd := NewStringCmd(ctx, "cluster", "myid")
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// ClusterSlots returns the mapping of cluster slots to nodes.
|
|
//
|
|
// Deprecated: Use ClusterShards instead as of Redis 7.0.0.
|
|
func (c cmdable) ClusterSlots(ctx context.Context) *ClusterSlotsCmd {
|
|
cmd := NewClusterSlotsCmd(ctx, "cluster", "slots")
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
func (c cmdable) ClusterShards(ctx context.Context) *ClusterShardsCmd {
|
|
cmd := NewClusterShardsCmd(ctx, "cluster", "shards")
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
func (c cmdable) ClusterLinks(ctx context.Context) *ClusterLinksCmd {
|
|
cmd := NewClusterLinksCmd(ctx, "cluster", "links")
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
func (c cmdable) ClusterNodes(ctx context.Context) *StringCmd {
|
|
cmd := NewStringCmd(ctx, "cluster", "nodes")
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
func (c cmdable) ClusterMeet(ctx context.Context, host, port string) *StatusCmd {
|
|
cmd := NewStatusCmd(ctx, "cluster", "meet", host, port)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
func (c cmdable) ClusterForget(ctx context.Context, nodeID string) *StatusCmd {
|
|
cmd := NewStatusCmd(ctx, "cluster", "forget", nodeID)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
func (c cmdable) ClusterReplicate(ctx context.Context, nodeID string) *StatusCmd {
|
|
cmd := NewStatusCmd(ctx, "cluster", "replicate", nodeID)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
func (c cmdable) ClusterResetSoft(ctx context.Context) *StatusCmd {
|
|
cmd := NewStatusCmd(ctx, "cluster", "reset", "soft")
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
func (c cmdable) ClusterResetHard(ctx context.Context) *StatusCmd {
|
|
cmd := NewStatusCmd(ctx, "cluster", "reset", "hard")
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
func (c cmdable) ClusterInfo(ctx context.Context) *StringCmd {
|
|
cmd := NewStringCmd(ctx, "cluster", "info")
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
func (c cmdable) ClusterKeySlot(ctx context.Context, key string) *IntCmd {
|
|
cmd := NewIntCmd(ctx, "cluster", "keyslot", key)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
func (c cmdable) ClusterGetKeysInSlot(ctx context.Context, slot int, count int) *StringSliceCmd {
|
|
cmd := NewStringSliceCmd(ctx, "cluster", "getkeysinslot", slot, count)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
func (c cmdable) ClusterCountFailureReports(ctx context.Context, nodeID string) *IntCmd {
|
|
cmd := NewIntCmd(ctx, "cluster", "count-failure-reports", nodeID)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
func (c cmdable) ClusterCountKeysInSlot(ctx context.Context, slot int) *IntCmd {
|
|
cmd := NewIntCmd(ctx, "cluster", "countkeysinslot", slot)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
func (c cmdable) ClusterDelSlots(ctx context.Context, slots ...int) *StatusCmd {
|
|
args := make([]interface{}, 2+len(slots))
|
|
args[0] = "cluster"
|
|
args[1] = "delslots"
|
|
for i, slot := range slots {
|
|
args[2+i] = slot
|
|
}
|
|
cmd := NewStatusCmd(ctx, args...)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
func (c cmdable) ClusterDelSlotsRange(ctx context.Context, min, max int) *StatusCmd {
|
|
size := max - min + 1
|
|
slots := make([]int, size)
|
|
for i := 0; i < size; i++ {
|
|
slots[i] = min + i
|
|
}
|
|
return c.ClusterDelSlots(ctx, slots...)
|
|
}
|
|
|
|
func (c cmdable) ClusterSaveConfig(ctx context.Context) *StatusCmd {
|
|
cmd := NewStatusCmd(ctx, "cluster", "saveconfig")
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// ClusterSlaves lists the replica nodes of a master node.
|
|
//
|
|
// Deprecated: Use ClusterReplicas instead as of Redis 5.0.0.
|
|
func (c cmdable) ClusterSlaves(ctx context.Context, nodeID string) *StringSliceCmd {
|
|
cmd := NewStringSliceCmd(ctx, "cluster", "slaves", nodeID)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
func (c cmdable) ClusterFailover(ctx context.Context) *StatusCmd {
|
|
cmd := NewStatusCmd(ctx, "cluster", "failover")
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
func (c cmdable) ClusterAddSlots(ctx context.Context, slots ...int) *StatusCmd {
|
|
args := make([]interface{}, 2+len(slots))
|
|
args[0] = "cluster"
|
|
args[1] = "addslots"
|
|
for i, num := range slots {
|
|
args[2+i] = num
|
|
}
|
|
cmd := NewStatusCmd(ctx, args...)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
func (c cmdable) ClusterAddSlotsRange(ctx context.Context, min, max int) *StatusCmd {
|
|
size := max - min + 1
|
|
slots := make([]int, size)
|
|
for i := 0; i < size; i++ {
|
|
slots[i] = min + i
|
|
}
|
|
return c.ClusterAddSlots(ctx, slots...)
|
|
}
|
|
|
|
func (c cmdable) ReadOnly(ctx context.Context) *StatusCmd {
|
|
cmd := NewStatusCmd(ctx, "readonly")
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
func (c cmdable) ReadWrite(ctx context.Context) *StatusCmd {
|
|
cmd := NewStatusCmd(ctx, "readwrite")
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|