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.
80 lines
3.5 KiB
Go
80 lines
3.5 KiB
Go
// Package auth package provides authentication-related interfaces and types.
|
|
// It also includes a basic implementation of credentials using username and password.
|
|
package auth
|
|
|
|
// StreamingCredentialsProvider is an interface that defines the methods for a streaming credentials provider.
|
|
// It is used to provide credentials for authentication.
|
|
// The CredentialsListener is used to receive updates when the credentials change.
|
|
type StreamingCredentialsProvider interface {
|
|
// Subscribe subscribes to the credentials provider for updates.
|
|
// It returns the current credentials, a cancel function to unsubscribe from the provider,
|
|
// and an error if any.
|
|
//
|
|
// Implementations MUST be idempotent with respect to listener identity:
|
|
// subscribing the same listener value more than once must not produce
|
|
// duplicate notifications and must not create multiple independent
|
|
// subscriptions that each need to be cancelled separately. Every
|
|
// UnsubscribeFunc returned for a given listener must cancel that
|
|
// listener's subscription; calling any one of them must be sufficient to
|
|
// stop updates to that listener, and calling subsequent ones must be a
|
|
// safe no-op. Callers (including go-redis internals) may retain only
|
|
// the most recently returned UnsubscribeFunc and rely on it to fully
|
|
// unsubscribe the listener.
|
|
//
|
|
// TODO(ndyakov): Should we add context to the Subscribe method?
|
|
Subscribe(listener CredentialsListener) (Credentials, UnsubscribeFunc, error)
|
|
}
|
|
|
|
// UnsubscribeFunc is a function that is used to cancel the subscription to the credentials provider.
|
|
// It is used to unsubscribe from the provider when the credentials are no longer needed.
|
|
//
|
|
// Per the StreamingCredentialsProvider.Subscribe contract, if the same
|
|
// listener is subscribed multiple times, every UnsubscribeFunc returned for
|
|
// that listener must fully unsubscribe it on first invocation, and
|
|
// subsequent invocations (from any of the equivalent UnsubscribeFuncs) must
|
|
// be a safe no-op.
|
|
type UnsubscribeFunc func() error
|
|
|
|
// CredentialsListener is an interface that defines the methods for a credentials listener.
|
|
// It is used to receive updates when the credentials change.
|
|
// The OnNext method is called when the credentials change.
|
|
// The OnError method is called when an error occurs while requesting the credentials.
|
|
type CredentialsListener interface {
|
|
OnNext(credentials Credentials)
|
|
OnError(err error)
|
|
}
|
|
|
|
// Credentials is an interface that defines the methods for credentials.
|
|
// It is used to provide the credentials for authentication.
|
|
type Credentials interface {
|
|
// BasicAuth returns the username and password for basic authentication.
|
|
BasicAuth() (username string, password string)
|
|
// RawCredentials returns the raw credentials as a string.
|
|
// This can be used to extract the username and password from the raw credentials or
|
|
// additional information if present in the token.
|
|
RawCredentials() string
|
|
}
|
|
|
|
type basicAuth struct {
|
|
username string
|
|
password string
|
|
}
|
|
|
|
// RawCredentials returns the raw credentials as a string.
|
|
func (b *basicAuth) RawCredentials() string {
|
|
return b.username + ":" + b.password
|
|
}
|
|
|
|
// BasicAuth returns the username and password for basic authentication.
|
|
func (b *basicAuth) BasicAuth() (username string, password string) {
|
|
return b.username, b.password
|
|
}
|
|
|
|
// NewBasicCredentials creates a new Credentials object from the given username and password.
|
|
func NewBasicCredentials(username, password string) Credentials {
|
|
return &basicAuth{
|
|
username: username,
|
|
password: password,
|
|
}
|
|
}
|