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.
388 lines
12 KiB
Go
388 lines
12 KiB
Go
package redis
|
|
|
|
import (
|
|
"context"
|
|
)
|
|
|
|
// note: the APIs is experimental and may be subject to change.
|
|
//
|
|
// ArrayCmdable defines the interface for Redis Array data structure commands
|
|
// available in Redis 8.8.0+.
|
|
//
|
|
// Redis array supports index range [0, math.MaxUint64-1), so index parameters use uint64.
|
|
type ArrayCmdable interface {
|
|
ARSet(ctx context.Context, key string, index uint64, values ...string) *IntCmd
|
|
ARGet(ctx context.Context, key string, index uint64) *StringCmd
|
|
ARGetRange(ctx context.Context, key string, start, end uint64) *SliceCmd
|
|
ARMGet(ctx context.Context, key string, indexes ...uint64) *SliceCmd
|
|
ARMSet(ctx context.Context, key string, members ...AREntry) *IntCmd
|
|
ARInsert(ctx context.Context, key string, values ...string) *UintCmd
|
|
ARDel(ctx context.Context, key string, indexes ...uint64) *IntCmd
|
|
ARDelRange(ctx context.Context, key string, ranges ...ARRange) *UintCmd
|
|
ARLen(ctx context.Context, key string) *UintCmd
|
|
ARCount(ctx context.Context, key string) *UintCmd
|
|
ARNext(ctx context.Context, key string) *UintCmd
|
|
ARSeek(ctx context.Context, key string, index uint64) *IntCmd
|
|
ARInfo(ctx context.Context, key string) *MapStringInterfaceCmd
|
|
ARInfoFull(ctx context.Context, key string) *MapStringInterfaceCmd
|
|
ARScan(ctx context.Context, key string, start, end uint64, args *ARScanArgs) *AREntrySliceCmd
|
|
AROpSum(ctx context.Context, key string, start, end uint64) *StringCmd
|
|
AROpMin(ctx context.Context, key string, start, end uint64) *StringCmd
|
|
AROpMax(ctx context.Context, key string, start, end uint64) *StringCmd
|
|
AROpAnd(ctx context.Context, key string, start, end uint64) *IntCmd
|
|
AROpOr(ctx context.Context, key string, start, end uint64) *IntCmd
|
|
AROpXor(ctx context.Context, key string, start, end uint64) *IntCmd
|
|
AROpMatch(ctx context.Context, key string, start, end uint64, value string) *IntCmd
|
|
AROpUsed(ctx context.Context, key string, start, end uint64) *IntCmd
|
|
ARGrep(ctx context.Context, key string, start, end string, args *ARGrepArgs) *UintSliceCmd
|
|
ARGrepWithValues(ctx context.Context, key string, start, end string, args *ARGrepArgs) *AREntrySliceCmd
|
|
ARRing(ctx context.Context, key string, size uint64, values ...string) *UintCmd
|
|
ARLastItems(ctx context.Context, key string, count uint64, rev bool) *SliceCmd
|
|
}
|
|
|
|
// AREntry represents an index-value pair for ARMSET.
|
|
type AREntry struct {
|
|
Index uint64
|
|
Value string
|
|
}
|
|
|
|
// ARRange represents a start-end range for ARDELRANGE.
|
|
type ARRange struct {
|
|
Start uint64
|
|
End uint64
|
|
}
|
|
|
|
// ARScanArgs contains optional arguments for ARSCAN.
|
|
type ARScanArgs struct {
|
|
Limit uint64
|
|
}
|
|
|
|
// ARGrepPredicateType defines the type of predicate for ARGREP.
|
|
type ARGrepPredicateType string
|
|
|
|
const (
|
|
ARGrepExact ARGrepPredicateType = "EXACT"
|
|
ARGrepMatch ARGrepPredicateType = "MATCH"
|
|
ARGrepGlob ARGrepPredicateType = "GLOB"
|
|
ARGrepRegex ARGrepPredicateType = "RE"
|
|
)
|
|
|
|
// ARGrepPredicate represents a search predicate for ARGREP.
|
|
type ARGrepPredicate struct {
|
|
Type ARGrepPredicateType
|
|
Value string
|
|
}
|
|
|
|
// ARGrepArgs contains optional arguments for ARGREP.
|
|
// Redis ARGREP defaults to OR when multiple predicates are given.
|
|
// Set CombineAnd to true to combine predicates with AND instead.
|
|
type ARGrepArgs struct {
|
|
Predicates []ARGrepPredicate
|
|
CombineAnd bool
|
|
Limit uint64
|
|
NoCase bool
|
|
}
|
|
|
|
// ARSet sets one or more contiguous values starting at an index in an array.
|
|
// Returns the number of new slots that were set (previously empty).
|
|
func (c cmdable) ARSet(ctx context.Context, key string, index uint64, values ...string) *IntCmd {
|
|
args := make([]any, 3, 3+len(values))
|
|
args[0] = "arset"
|
|
args[1] = key
|
|
args[2] = index
|
|
for _, v := range values {
|
|
args = append(args, v)
|
|
}
|
|
cmd := NewIntCmd(ctx, args...)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// ARGet gets the value at an index in an array.
|
|
// Returns redis.Nil if the key or index does not exist.
|
|
func (c cmdable) ARGet(ctx context.Context, key string, index uint64) *StringCmd {
|
|
cmd := NewStringCmd(ctx, "arget", key, index)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// ARGetRange gets values in a range of indexes.
|
|
// Returns values in the range, with nil for unset indexes.
|
|
func (c cmdable) ARGetRange(ctx context.Context, key string, start, end uint64) *SliceCmd {
|
|
cmd := NewSliceCmd(ctx, "argetrange", key, start, end)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// ARMGet gets values at multiple indexes in an array.
|
|
// Returns values at the specified indexes, with nil for unset indexes.
|
|
func (c cmdable) ARMGet(ctx context.Context, key string, indexes ...uint64) *SliceCmd {
|
|
args := make([]any, 2+len(indexes))
|
|
args[0] = "armget"
|
|
args[1] = key
|
|
for i, idx := range indexes {
|
|
args[2+i] = idx
|
|
}
|
|
cmd := NewSliceCmd(ctx, args...)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// ARMSet sets multiple index-value pairs in an array.
|
|
// Returns the number of new slots that were set (previously empty).
|
|
func (c cmdable) ARMSet(ctx context.Context, key string, members ...AREntry) *IntCmd {
|
|
args := make([]any, 2, 2+2*len(members))
|
|
args[0] = "armset"
|
|
args[1] = key
|
|
for _, m := range members {
|
|
args = append(args, m.Index, m.Value)
|
|
}
|
|
cmd := NewIntCmd(ctx, args...)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// ARInsert inserts one or more values at consecutive indexes.
|
|
// Returns the last index where a value was inserted.
|
|
func (c cmdable) ARInsert(ctx context.Context, key string, values ...string) *UintCmd {
|
|
args := make([]any, 2, 2+len(values))
|
|
args[0] = "arinsert"
|
|
args[1] = key
|
|
for _, v := range values {
|
|
args = append(args, v)
|
|
}
|
|
cmd := NewUintCmd(ctx, args...)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// ARDel deletes elements at the specified indexes in an array.
|
|
// Returns the number of elements deleted.
|
|
func (c cmdable) ARDel(ctx context.Context, key string, indexes ...uint64) *IntCmd {
|
|
args := make([]any, 2+len(indexes))
|
|
args[0] = "ardel"
|
|
args[1] = key
|
|
for i, idx := range indexes {
|
|
args[2+i] = idx
|
|
}
|
|
cmd := NewIntCmd(ctx, args...)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// ARDelRange deletes elements in one or more ranges.
|
|
// Returns the number of elements deleted.
|
|
func (c cmdable) ARDelRange(ctx context.Context, key string, ranges ...ARRange) *UintCmd {
|
|
args := make([]any, 2, 2+2*len(ranges))
|
|
args[0] = "ardelrange"
|
|
args[1] = key
|
|
for _, r := range ranges {
|
|
args = append(args, r.Start, r.End)
|
|
}
|
|
cmd := NewUintCmd(ctx, args...)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// ARLen returns the length of an array (max index + 1).
|
|
// Returns 0 if the key does not exist.
|
|
func (c cmdable) ARLen(ctx context.Context, key string) *UintCmd {
|
|
cmd := NewUintCmd(ctx, "arlen", key)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// ARCount returns the number of non-empty elements in an array.
|
|
// Returns 0 if the key does not exist.
|
|
func (c cmdable) ARCount(ctx context.Context, key string) *UintCmd {
|
|
cmd := NewUintCmd(ctx, "arcount", key)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// ARNext returns the next index ARINSERT would use.
|
|
// Returns 0 for missing keys or when no insert happened yet.
|
|
// Returns nil when the insertion cursor is exhausted / would overflow.
|
|
func (c cmdable) ARNext(ctx context.Context, key string) *UintCmd {
|
|
cmd := NewUintCmd(ctx, "arnext", key)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// ARSeek sets the ARINSERT / ARRING cursor to a specific index.
|
|
// Returns 1 if the cursor was set, 0 if the key does not exist.
|
|
func (c cmdable) ARSeek(ctx context.Context, key string, index uint64) *IntCmd {
|
|
cmd := NewIntCmd(ctx, "arseek", key, index)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// ARInfo returns metadata about an array.
|
|
func (c cmdable) ARInfo(ctx context.Context, key string) *MapStringInterfaceCmd {
|
|
cmd := NewMapStringInterfaceCmd(ctx, "arinfo", key)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// ARInfoFull returns detailed metadata about an array including slice statistics.
|
|
func (c cmdable) ARInfoFull(ctx context.Context, key string) *MapStringInterfaceCmd {
|
|
cmd := NewMapStringInterfaceCmd(ctx, "arinfo", key, "full")
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// ARScan iterates existing elements in a range, returning index-value pairs.
|
|
func (c cmdable) ARScan(ctx context.Context, key string, start, end uint64, scanArgs *ARScanArgs) *AREntrySliceCmd {
|
|
args := make([]any, 4, 6)
|
|
args[0], args[1], args[2], args[3] = "arscan", key, start, end
|
|
if scanArgs != nil && scanArgs.Limit > 0 {
|
|
args = append(args, "limit", scanArgs.Limit)
|
|
}
|
|
cmd := NewAREntrySliceCmd(ctx, args...)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// AROpSum returns the sum of numeric elements in a range.
|
|
func (c cmdable) AROpSum(ctx context.Context, key string, start, end uint64) *StringCmd {
|
|
cmd := NewStringCmd(ctx, "arop", key, start, end, "SUM")
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// AROpMin returns the minimum numeric element in a range.
|
|
func (c cmdable) AROpMin(ctx context.Context, key string, start, end uint64) *StringCmd {
|
|
cmd := NewStringCmd(ctx, "arop", key, start, end, "MIN")
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// AROpMax returns the maximum numeric element in a range.
|
|
func (c cmdable) AROpMax(ctx context.Context, key string, start, end uint64) *StringCmd {
|
|
cmd := NewStringCmd(ctx, "arop", key, start, end, "MAX")
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// AROpAnd returns the bitwise AND of integer elements in a range.
|
|
func (c cmdable) AROpAnd(ctx context.Context, key string, start, end uint64) *IntCmd {
|
|
cmd := NewIntCmd(ctx, "arop", key, start, end, "AND")
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// AROpOr returns the bitwise OR of integer elements in a range.
|
|
func (c cmdable) AROpOr(ctx context.Context, key string, start, end uint64) *IntCmd {
|
|
cmd := NewIntCmd(ctx, "arop", key, start, end, "OR")
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// AROpXor returns the bitwise XOR of integer elements in a range.
|
|
func (c cmdable) AROpXor(ctx context.Context, key string, start, end uint64) *IntCmd {
|
|
cmd := NewIntCmd(ctx, "arop", key, start, end, "XOR")
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// AROpMatch returns the count of elements matching a target string in a range.
|
|
func (c cmdable) AROpMatch(ctx context.Context, key string, start, end uint64, value string) *IntCmd {
|
|
cmd := NewIntCmd(ctx, "arop", key, start, end, "MATCH", value)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// AROpUsed returns the count of non-empty slots in a range.
|
|
func (c cmdable) AROpUsed(ctx context.Context, key string, start, end uint64) *IntCmd {
|
|
cmd := NewIntCmd(ctx, "arop", key, start, end, "USED")
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// ARGrep searches array elements in a range using textual predicates.
|
|
// Returns matching indexes only. Use ARGrepWithValues to also get the values.
|
|
func (c cmdable) ARGrep(ctx context.Context, key string, start, end string, grepArgs *ARGrepArgs) *UintSliceCmd {
|
|
args := make([]any, 4, 4+grepArgs.Len())
|
|
args[0], args[1], args[2], args[3] = "argrep", key, start, end
|
|
args = grepArgs.Append(args)
|
|
cmd := NewUintSliceCmd(ctx, args...)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// ARGrepWithValues searches array elements in a range using textual predicates.
|
|
// Returns matching indexes and their values as index-value pairs.
|
|
func (c cmdable) ARGrepWithValues(ctx context.Context, key string, start, end string, grepArgs *ARGrepArgs) *AREntrySliceCmd {
|
|
args := make([]any, 4, 5+grepArgs.Len())
|
|
args[0], args[1], args[2], args[3] = "argrep", key, start, end
|
|
args = grepArgs.Append(args)
|
|
args = append(args, "withvalues")
|
|
cmd := NewAREntrySliceCmd(ctx, args...)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
func (args *ARGrepArgs) Len() int {
|
|
if args == nil {
|
|
return 0
|
|
}
|
|
n := 2 * len(args.Predicates)
|
|
if args.CombineAnd {
|
|
n++
|
|
}
|
|
if args.Limit > 0 {
|
|
n += 2
|
|
}
|
|
if args.NoCase {
|
|
n++
|
|
}
|
|
return n
|
|
}
|
|
|
|
func (args *ARGrepArgs) Append(a []any) []any {
|
|
if args == nil {
|
|
return a
|
|
}
|
|
for _, p := range args.Predicates {
|
|
a = append(a, string(p.Type), p.Value)
|
|
}
|
|
if args.CombineAnd {
|
|
a = append(a, "and")
|
|
}
|
|
if args.Limit > 0 {
|
|
a = append(a, "limit", args.Limit)
|
|
}
|
|
if args.NoCase {
|
|
a = append(a, "nocase")
|
|
}
|
|
return a
|
|
}
|
|
|
|
// ARRing inserts values into a ring buffer of specified size, wrapping and truncating as needed.
|
|
// Returns the last index where a value was inserted.
|
|
func (c cmdable) ARRing(ctx context.Context, key string, size uint64, values ...string) *UintCmd {
|
|
args := make([]any, 3, 3+len(values))
|
|
args[0] = "arring"
|
|
args[1] = key
|
|
args[2] = size
|
|
for _, v := range values {
|
|
args = append(args, v)
|
|
}
|
|
cmd := NewUintCmd(ctx, args...)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|
|
|
|
// ARLastItems returns the most recently inserted elements.
|
|
// When rev is true, returns items in reverse order.
|
|
func (c cmdable) ARLastItems(ctx context.Context, key string, count uint64, rev bool) *SliceCmd {
|
|
args := make([]any, 3, 4)
|
|
args[0], args[1], args[2] = "arlastitems", key, count
|
|
if rev {
|
|
args = append(args, "rev")
|
|
}
|
|
cmd := NewSliceCmd(ctx, args...)
|
|
_ = c(ctx, cmd)
|
|
return cmd
|
|
}
|