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.
59 lines
2.0 KiB
Go
59 lines
2.0 KiB
Go
//go:build !appengine
|
|
// +build !appengine
|
|
|
|
// This file encapsulates usage of unsafe.
|
|
// xxhash_safe.go contains the safe implementations.
|
|
|
|
package xxhash
|
|
|
|
import (
|
|
"unsafe"
|
|
)
|
|
|
|
// In the future it's possible that compiler optimizations will make these
|
|
// XxxString functions unnecessary by realizing that calls such as
|
|
// Sum64([]byte(s)) don't need to copy s. See https://go.dev/issue/2205.
|
|
// If that happens, even if we keep these functions they can be replaced with
|
|
// the trivial safe code.
|
|
|
|
// NOTE: The usual way of doing an unsafe string-to-[]byte conversion is:
|
|
//
|
|
// var b []byte
|
|
// bh := (*reflect.SliceHeader)(unsafe.Pointer(&b))
|
|
// bh.Data = (*reflect.StringHeader)(unsafe.Pointer(&s)).Data
|
|
// bh.Len = len(s)
|
|
// bh.Cap = len(s)
|
|
//
|
|
// Unfortunately, as of Go 1.15.3 the inliner's cost model assigns a high enough
|
|
// weight to this sequence of expressions that any function that uses it will
|
|
// not be inlined. Instead, the functions below use a different unsafe
|
|
// conversion designed to minimize the inliner weight and allow both to be
|
|
// inlined. There is also a test (TestInlining) which verifies that these are
|
|
// inlined.
|
|
//
|
|
// See https://github.com/golang/go/issues/42739 for discussion.
|
|
|
|
// Sum64String computes the 64-bit xxHash digest of s with a zero seed.
|
|
// It may be faster than Sum64([]byte(s)) by avoiding a copy.
|
|
func Sum64String(s string) uint64 {
|
|
b := *(*[]byte)(unsafe.Pointer(&sliceHeader{s, len(s)}))
|
|
return Sum64(b)
|
|
}
|
|
|
|
// WriteString adds more data to d. It always returns len(s), nil.
|
|
// It may be faster than Write([]byte(s)) by avoiding a copy.
|
|
func (d *Digest) WriteString(s string) (n int, err error) {
|
|
d.Write(*(*[]byte)(unsafe.Pointer(&sliceHeader{s, len(s)})))
|
|
// d.Write always returns len(s), nil.
|
|
// Ignoring the return output and returning these fixed values buys a
|
|
// savings of 6 in the inliner's cost model.
|
|
return len(s), nil
|
|
}
|
|
|
|
// sliceHeader is similar to reflect.SliceHeader, but it assumes that the layout
|
|
// of the first two words is the same as the layout of a string.
|
|
type sliceHeader struct {
|
|
s string
|
|
cap int
|
|
}
|