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.
474 lines
17 KiB
Go
474 lines
17 KiB
Go
package redis
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/redis/go-redis/v9/internal"
|
|
"github.com/redis/go-redis/v9/internal/pool"
|
|
"github.com/redis/go-redis/v9/internal/proto"
|
|
)
|
|
|
|
// himportFieldset is the client-side record of a fieldset registered with
|
|
// HImportPrepare.
|
|
type himportFieldset struct {
|
|
fields []string
|
|
version uint64
|
|
}
|
|
|
|
// himportRegistry remembers fieldsets registered through a client so HIMPORT
|
|
// SET can lazily prepare them on whichever pooled connection it executes.
|
|
// Versions increase monotonically and start at 1; re-registering a name under
|
|
// a new version invalidates every connection's prepared flag for it, so a
|
|
// replaced fieldset is re-prepared before its next use.
|
|
//
|
|
// Discards propagate lazily as well: a discarded name is kept as a tombstone
|
|
// and the discard-all counter as an epoch, and connections whose sessions
|
|
// still hold discarded fieldsets replay HIMPORT DISCARD/DISCARDALL before
|
|
// their next HIMPORT command (see baseClient.himportInjectedCmds).
|
|
type himportRegistry struct {
|
|
mu sync.RWMutex
|
|
nextVersion uint64
|
|
fieldsets map[string]himportFieldset
|
|
// tombstones holds names discarded through this client whose server-side
|
|
// copies may survive on pooled connections that prepared them. An entry
|
|
// is removed when the name is registered again (the new version replaces
|
|
// the fieldset on the server, so no discard is needed) or by discardAll.
|
|
// Known limitation: a workload discarding many uniquely-named fieldsets
|
|
// grows this map for the client's lifetime and pays an O(tombstones)
|
|
// snapshot per HIMPORT round trip; HImportDiscardAll resets it.
|
|
tombstones map[string]struct{}
|
|
// discardAllEpoch increments on every successful HImportDiscardAll.
|
|
discardAllEpoch uint64
|
|
}
|
|
|
|
func newHImportRegistry() *himportRegistry {
|
|
return &himportRegistry{}
|
|
}
|
|
|
|
// register stores the fieldset and returns its new version together with the
|
|
// current discard-all epoch.
|
|
func (r *himportRegistry) register(name string, fields []string) (version, epoch uint64) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.fieldsets == nil {
|
|
r.fieldsets = make(map[string]himportFieldset)
|
|
}
|
|
delete(r.tombstones, name)
|
|
r.nextVersion++
|
|
r.fieldsets[name] = himportFieldset{
|
|
fields: append([]string(nil), fields...),
|
|
version: r.nextVersion,
|
|
}
|
|
return r.nextVersion, r.discardAllEpoch
|
|
}
|
|
|
|
func (r *himportRegistry) lookup(name string) (himportFieldset, bool) {
|
|
if r == nil {
|
|
return himportFieldset{}, false
|
|
}
|
|
r.mu.RLock()
|
|
fs, ok := r.fieldsets[name]
|
|
r.mu.RUnlock()
|
|
return fs, ok
|
|
}
|
|
|
|
// discard removes the fieldset and leaves a tombstone so connections whose
|
|
// sessions still hold it replay the DISCARD before their next HIMPORT
|
|
// command. It reports whether the fieldset was registered.
|
|
func (r *himportRegistry) discard(name string) bool {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if _, ok := r.fieldsets[name]; !ok {
|
|
return false
|
|
}
|
|
delete(r.fieldsets, name)
|
|
if r.tombstones == nil {
|
|
r.tombstones = make(map[string]struct{})
|
|
}
|
|
r.tombstones[name] = struct{}{}
|
|
return true
|
|
}
|
|
|
|
// discardAll drops every fieldset and tombstone and moves to a new epoch;
|
|
// connections prepared under an older epoch replay HIMPORT DISCARDALL before
|
|
// their next HIMPORT command. It returns the new epoch and the number of
|
|
// fieldsets that were registered.
|
|
func (r *himportRegistry) discardAll() (epoch uint64, removed int) {
|
|
r.mu.Lock()
|
|
removed = len(r.fieldsets)
|
|
r.fieldsets = nil
|
|
r.tombstones = nil
|
|
r.discardAllEpoch++
|
|
epoch = r.discardAllEpoch
|
|
r.mu.Unlock()
|
|
return epoch, removed
|
|
}
|
|
|
|
// discardVersion withdraws a registration whose fan-out PREPARE was rejected
|
|
// by a server — but only while the entry is still at that version, so a
|
|
// concurrent re-registration is not clobbered. A tombstone is left: the
|
|
// fan-out may have succeeded on some masters before another rejected it
|
|
// (per-node ACLs, rolling upgrades), and those sessions hold the withdrawn
|
|
// fieldset; the tombstone makes their next HIMPORT command discard it
|
|
// instead of leaving a fieldset the client can no longer address.
|
|
func (r *himportRegistry) discardVersion(name string, version uint64) {
|
|
r.mu.Lock()
|
|
if fs, ok := r.fieldsets[name]; ok && fs.version == version {
|
|
delete(r.fieldsets, name)
|
|
if r.tombstones == nil {
|
|
r.tombstones = make(map[string]struct{})
|
|
}
|
|
r.tombstones[name] = struct{}{}
|
|
}
|
|
r.mu.Unlock()
|
|
}
|
|
|
|
// refreshVersion bumps a registered fieldset to a new version, keeping its
|
|
// fields — but only while the entry is still at the given version, so a
|
|
// concurrent re-registration is not disturbed. Every connection's prepared
|
|
// flag becomes stale, forcing a re-prepare before the fieldset's next use on
|
|
// each of them. Used when a "no such fieldset" reply signals session loss
|
|
// that may have hit more connections than the one that reported it (failover,
|
|
// cross-region switch, reset storms).
|
|
func (r *himportRegistry) refreshVersion(name string, version uint64) {
|
|
r.mu.Lock()
|
|
if fs, ok := r.fieldsets[name]; ok && fs.version == version {
|
|
r.nextVersion++
|
|
fs.version = r.nextVersion
|
|
r.fieldsets[name] = fs
|
|
}
|
|
r.mu.Unlock()
|
|
}
|
|
|
|
// idle reports whether the registry implies no injection work at all: no
|
|
// fieldsets to replay, no tombstones to discard, and no discard-all epoch a
|
|
// session could be behind.
|
|
func (r *himportRegistry) idle() bool {
|
|
if r == nil {
|
|
return true
|
|
}
|
|
r.mu.RLock()
|
|
idle := len(r.fieldsets) == 0 && len(r.tombstones) == 0 && r.discardAllEpoch == 0
|
|
r.mu.RUnlock()
|
|
return idle
|
|
}
|
|
|
|
// cleanupSnapshot returns the current epoch and the tombstoned names.
|
|
func (r *himportRegistry) cleanupSnapshot() (epoch uint64, tombstones []string) {
|
|
r.mu.RLock()
|
|
epoch = r.discardAllEpoch
|
|
if len(r.tombstones) > 0 {
|
|
tombstones = make([]string, 0, len(r.tombstones))
|
|
for name := range r.tombstones {
|
|
tombstones = append(tombstones, name)
|
|
}
|
|
}
|
|
r.mu.RUnlock()
|
|
return epoch, tombstones
|
|
}
|
|
|
|
// himportNoSuchFieldset reports whether err is the server's "no such
|
|
// fieldset" reply, i.e. an HIMPORT SET executed on a connection whose session
|
|
// does not hold the referenced fieldset.
|
|
func himportNoSuchFieldset(err error) bool {
|
|
return isRedisError(err) && strings.Contains(err.Error(), "no such fieldset")
|
|
}
|
|
|
|
// himportInjectedCmds returns the HIMPORT commands to write to cn ahead of a
|
|
// batch, in order:
|
|
//
|
|
// 1. HIMPORT DISCARDALL when cn's session was prepared under an older
|
|
// discard-all epoch;
|
|
// 2. HIMPORT DISCARD for each discarded fieldset the session still holds;
|
|
// 3. HIMPORT PREPARE for each registered fieldset referenced by an HIMPORT
|
|
// SET in the batch that the session lacks at the current version.
|
|
//
|
|
// A fieldset covered by a user-issued PREPARE earlier in the batch needs no
|
|
// injection — the server session holds it by the time the SET runs. Returns
|
|
// nil when the batch contains no HIMPORT commands: sessions holding only
|
|
// discarded fieldsets are cleaned up on their next HIMPORT use, not on
|
|
// unrelated traffic.
|
|
func (c *baseClient) himportInjectedCmds(ctx context.Context, cn *pool.Conn, cmds []Cmder) []Cmder {
|
|
if c.himport.idle() {
|
|
return nil
|
|
}
|
|
hasHImport := false
|
|
for _, cmd := range cmds {
|
|
if _, ok := cmd.(himportCmder); ok {
|
|
hasHImport = true
|
|
break
|
|
}
|
|
}
|
|
if !hasHImport {
|
|
return nil
|
|
}
|
|
|
|
var injected []Cmder
|
|
|
|
// Discards first: a session behind the discard-all epoch is wiped
|
|
// entirely; otherwise individual tombstoned fieldsets it still holds are
|
|
// discarded.
|
|
epoch, tombstones := c.himport.cleanupSnapshot()
|
|
sessionWiped := false
|
|
if cn.HasPreparedFieldsets() && cn.FieldsetEpoch() != epoch {
|
|
da := NewHImportDiscardAllCmd(ctx)
|
|
da.registryEpoch = epoch
|
|
injected = append(injected, da)
|
|
sessionWiped = true
|
|
} else {
|
|
for _, name := range tombstones {
|
|
if cn.FieldsetPreparedVersion(name) != 0 {
|
|
injected = append(injected, NewHImportDiscardCmd(ctx, name))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Prepares for registered fieldsets the batch's SETs reference.
|
|
var covered map[string]struct{}
|
|
cover := func(name string) {
|
|
if covered == nil {
|
|
covered = make(map[string]struct{})
|
|
}
|
|
covered[name] = struct{}{}
|
|
}
|
|
for _, cmd := range cmds {
|
|
switch hc := cmd.(type) {
|
|
case *HImportPrepareCmd:
|
|
cover(hc.fieldsetName)
|
|
case *HImportSetCmd:
|
|
if _, ok := covered[hc.fieldsetName]; ok {
|
|
continue
|
|
}
|
|
fs, ok := c.himport.lookup(hc.fieldsetName)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if !sessionWiped && cn.FieldsetPreparedVersion(hc.fieldsetName) == fs.version {
|
|
continue
|
|
}
|
|
// The session holds an older version. Discard it before the
|
|
// re-prepare: the SET behind it is already on the wire, and if
|
|
// the re-prepare fails the SET must answer "no such fieldset"
|
|
// rather than silently writing the old version's field names.
|
|
if !sessionWiped && cn.FieldsetPreparedVersion(hc.fieldsetName) != 0 {
|
|
injected = append(injected, NewHImportDiscardCmd(ctx, hc.fieldsetName))
|
|
}
|
|
prep := NewHImportPrepareCmd(ctx, hc.fieldsetName, fs.fields...)
|
|
prep.registryVersion = fs.version
|
|
prep.registryEpoch = epoch
|
|
injected = append(injected, prep)
|
|
cover(hc.fieldsetName)
|
|
}
|
|
}
|
|
return injected
|
|
}
|
|
|
|
// himportReadInjectedReplies consumes the replies of injected HIMPORT
|
|
// commands. Server errors are recorded on the command and the connection is
|
|
// left readable; transport errors are returned. Successful commands apply
|
|
// their prepared-flag bookkeeping on cn.
|
|
func (c *baseClient) himportReadInjectedReplies(ctx context.Context, cn *pool.Conn, rd *proto.Reader, injected []Cmder) error {
|
|
for _, cmd := range injected {
|
|
if err := c.processPendingPushNotificationWithReader(ctx, cn, rd); err != nil {
|
|
internal.Logger.Printf(ctx, "push: error processing pending notifications before reading reply: %v", err)
|
|
}
|
|
err := cmd.readReply(rd)
|
|
cmd.SetErr(err)
|
|
if err != nil {
|
|
if !isRedisError(err) {
|
|
return err
|
|
}
|
|
// A failed injected PREPARE becomes the root cause of the
|
|
// dependent SETs' errors downstream; a failed injected discard
|
|
// only delays cleanup until the next HIMPORT command.
|
|
internal.Logger.Printf(ctx, "himport: injected %s failed: %v", cmd.Name(), err)
|
|
continue
|
|
}
|
|
switch hc := cmd.(type) {
|
|
case *HImportPrepareCmd:
|
|
cn.MarkFieldsetPrepared(hc.fieldsetName, hc.registryVersion, hc.registryEpoch)
|
|
case *HImportDiscardCmd:
|
|
cn.UnmarkFieldsetPrepared(hc.fieldsetName)
|
|
case *HImportDiscardAllCmd:
|
|
cn.ClearPreparedFieldsets(hc.registryEpoch)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// himportAfterCmd applies registry and prepared-flag updates after a
|
|
// user-issued HIMPORT command completed successfully on cn.
|
|
func (c *baseClient) himportAfterCmd(cn *pool.Conn, hc himportCmder) {
|
|
if c.himport == nil {
|
|
return
|
|
}
|
|
switch cmd := hc.(type) {
|
|
case *HImportPrepareCmd:
|
|
version, epoch := cmd.registryVersion, cmd.registryEpoch
|
|
if version == 0 {
|
|
version, epoch = c.himport.register(cmd.fieldsetName, cmd.fields)
|
|
}
|
|
// A pre-assigned version marks a fan-out copy: the fieldset was
|
|
// registered once at the cluster/ring level; only mark the
|
|
// executing connection.
|
|
cn.MarkFieldsetPrepared(cmd.fieldsetName, version, epoch)
|
|
case *HImportDiscardCmd:
|
|
registered := c.himport.discard(cmd.fieldsetName)
|
|
cn.UnmarkFieldsetPrepared(cmd.fieldsetName)
|
|
// The managed API reports the registry lifecycle: 1 when the
|
|
// fieldset was registered on this client and is now removed. The
|
|
// executing connection's session count stands only for fieldsets
|
|
// the registry never knew (raw usage).
|
|
if registered {
|
|
cmd.SetVal(1)
|
|
}
|
|
case *HImportDiscardAllCmd:
|
|
// A pre-assigned epoch marks a fan-out copy: the registry was
|
|
// already wiped at the cluster/ring level; only move the executing
|
|
// connection to that epoch.
|
|
if cmd.registryEpoch != 0 {
|
|
cn.ClearPreparedFieldsets(cmd.registryEpoch)
|
|
return
|
|
}
|
|
epoch, removed := c.himport.discardAll()
|
|
cn.ClearPreparedFieldsets(epoch)
|
|
// Same registry semantics: report how many registered fieldsets
|
|
// were removed, not how many the executing session happened to
|
|
// hold.
|
|
if removed > 0 {
|
|
cmd.SetVal(int64(removed))
|
|
}
|
|
}
|
|
}
|
|
|
|
// himportAfterBatch runs after all replies of a batch were read: it surfaces
|
|
// an injected PREPARE failure as the root cause on the HIMPORT SET commands
|
|
// that depended on it (their own reply is the secondary "no such fieldset"
|
|
// error), invalidates stale prepared flags for SETs that found their
|
|
// registered fieldset missing server-side, and applies registry updates for
|
|
// user-issued HIMPORT commands that succeeded in the batch.
|
|
// rawErr throughout: this runs on the execution path, before an async
|
|
// autopipeline batch completes (its ready channel closes only after the
|
|
// pipeline hook chain returns) — Err() on a user command would await and
|
|
// self-deadlock the dispatcher.
|
|
func (c *baseClient) himportAfterBatch(cn *pool.Conn, injected []Cmder, cmds []Cmder) {
|
|
var failed map[string]error
|
|
var refreshed map[string]struct{}
|
|
for _, cmd := range injected {
|
|
if prep, ok := cmd.(*HImportPrepareCmd); ok {
|
|
if err := prep.Err(); err != nil {
|
|
if failed == nil {
|
|
failed = make(map[string]error)
|
|
}
|
|
failed[prep.fieldsetName] = err
|
|
}
|
|
}
|
|
}
|
|
for _, cmd := range cmds {
|
|
hc, ok := cmd.(himportCmder)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if set, ok := hc.(*HImportSetCmd); ok {
|
|
if rootCause, ok := failed[set.fieldsetName]; ok && himportNoSuchFieldset(set.rawErr()) {
|
|
set.SetErr(rootCause)
|
|
continue
|
|
}
|
|
// The session lost a fieldset the flags claim is prepared (e.g.
|
|
// RESET) — and the same event may have wiped other sessions
|
|
// whose flags also still look current. Bump the fieldset
|
|
// version once so the SET's re-issue, the cluster re-queue on
|
|
// whichever connection it lands, or the caller's transaction
|
|
// retry replays the PREPARE.
|
|
if himportNoSuchFieldset(set.rawErr()) {
|
|
if _, done := refreshed[set.fieldsetName]; !done {
|
|
if refreshed == nil {
|
|
refreshed = make(map[string]struct{})
|
|
}
|
|
refreshed[set.fieldsetName] = struct{}{}
|
|
if fs, registered := c.himport.lookup(set.fieldsetName); registered {
|
|
c.himport.refreshVersion(set.fieldsetName, fs.version)
|
|
}
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if hc.rawErr() == nil {
|
|
c.himportAfterCmd(cn, hc)
|
|
}
|
|
}
|
|
}
|
|
|
|
// himportRetryFailedSets re-issues, once, the HIMPORT SET commands of a
|
|
// pipeline batch that failed with "no such fieldset" while their fieldset is
|
|
// registered — the error must not surface for managed fieldsets (NF.4). Only
|
|
// the SETs are re-sent: HIMPORT SET is a full replace, so re-execution is
|
|
// idempotent, and no other command of the batch runs again. Their prepared
|
|
// flags were invalidated by himportAfterBatch, so himportInjectedCmds
|
|
// regenerates the PREPAREs for this connection. Transport errors are
|
|
// returned; server errors stay recorded on the commands.
|
|
// (The retry does not carry an ASKING prefix. A redirected [ASKING, SET]
|
|
// pair whose injected PREPARE failed is excluded by the root-cause swap in
|
|
// himportAfterBatch; one that lost its session without an injection can be
|
|
// re-issued here, and the bare SET then draws a fresh MOVED/ASK that the
|
|
// outer cluster redirect handling resolves.)
|
|
func (c *baseClient) himportRetryFailedSets(ctx context.Context, cn *pool.Conn, cmds []Cmder) error {
|
|
if c.himport.idle() {
|
|
return nil
|
|
}
|
|
var retry []Cmder
|
|
for _, cmd := range cmds {
|
|
// rawErr: execution path, same self-deadlock rule as himportAfterBatch.
|
|
if set, ok := cmd.(*HImportSetCmd); ok && himportNoSuchFieldset(set.rawErr()) {
|
|
if _, registered := c.himport.lookup(set.fieldsetName); registered {
|
|
retry = append(retry, set)
|
|
}
|
|
}
|
|
}
|
|
if len(retry) == 0 {
|
|
return nil
|
|
}
|
|
|
|
injected := c.himportInjectedCmds(ctx, cn, retry)
|
|
if err := cn.WithWriter(c.context(ctx), c.opt.WriteTimeout, func(wr *proto.Writer) error {
|
|
for _, ic := range injected {
|
|
if err := writeCmd(wr, ic); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return writeCmds(wr, retry)
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
return cn.WithReader(c.context(ctx), c.opt.ReadTimeout, func(rd *proto.Reader) error {
|
|
if err := c.himportReadInjectedReplies(ctx, cn, rd, injected); err != nil {
|
|
return err
|
|
}
|
|
err := c.pipelineReadCmds(ctx, cn, rd, retry)
|
|
if err != nil && !isRedisError(err) {
|
|
return err
|
|
}
|
|
// Server errors (including a repeated failure) stay on the
|
|
// individual commands; the batch as a whole is done.
|
|
c.himportAfterBatch(cn, injected, retry)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// himportShouldRetrySet reports whether a retry of cmd may succeed after it
|
|
// failed with "no such fieldset": true when the fieldset is registered
|
|
// client-side — the executing connection lost its server session state (for
|
|
// example a RESET, or a concurrent discard). The connection's prepared flag
|
|
// was already invalidated inside _process, while that goroutine still owned
|
|
// the connection, so the retry re-prepares lazily wherever it lands.
|
|
func (c *baseClient) himportShouldRetrySet(cmd Cmder, err error) bool {
|
|
set, ok := cmd.(*HImportSetCmd)
|
|
if !ok || !himportNoSuchFieldset(err) {
|
|
return false
|
|
}
|
|
_, registered := c.himport.lookup(set.fieldsetName)
|
|
return registered
|
|
}
|