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.
157 lines
4.6 KiB
Go
157 lines
4.6 KiB
Go
// Copyright 2019 The Go Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
package impl
|
|
|
|
import (
|
|
"reflect"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"google.golang.org/protobuf/reflect/protoreflect"
|
|
"google.golang.org/protobuf/runtime/protoiface"
|
|
)
|
|
|
|
// ExtensionInfo implements ExtensionType.
|
|
//
|
|
// This type contains a number of exported fields for legacy compatibility.
|
|
// The only non-deprecated use of this type is through the methods of the
|
|
// ExtensionType interface.
|
|
type ExtensionInfo struct {
|
|
// An ExtensionInfo may exist in several stages of initialization.
|
|
//
|
|
// extensionInfoUninitialized: Some or all of the legacy exported
|
|
// fields may be set, but none of the unexported fields have been
|
|
// initialized. This is the starting state for an ExtensionInfo
|
|
// in legacy generated code.
|
|
//
|
|
// extensionInfoDescInit: The desc field is set, but other unexported fields
|
|
// may not be initialized. Legacy exported fields may or may not be set.
|
|
// This is the starting state for an ExtensionInfo in newly generated code.
|
|
//
|
|
// extensionInfoFullInit: The ExtensionInfo is fully initialized.
|
|
// This state is only entered after lazy initialization is complete.
|
|
init uint32
|
|
mu sync.Mutex
|
|
|
|
goType reflect.Type
|
|
desc extensionTypeDescriptor
|
|
conv Converter
|
|
info *extensionFieldInfo // for fast-path method implementations
|
|
|
|
// ExtendedType is a typed nil-pointer to the parent message type that
|
|
// is being extended. It is possible for this to be unpopulated in v2
|
|
// since the message may no longer implement the MessageV1 interface.
|
|
//
|
|
// Deprecated: Use the ExtendedType method instead.
|
|
ExtendedType protoiface.MessageV1
|
|
|
|
// ExtensionType is the zero value of the extension type.
|
|
//
|
|
// For historical reasons, reflect.TypeOf(ExtensionType) and the
|
|
// type returned by InterfaceOf may not be identical.
|
|
//
|
|
// Deprecated: Use InterfaceOf(xt.Zero()) instead.
|
|
ExtensionType any
|
|
|
|
// Field is the field number of the extension.
|
|
//
|
|
// Deprecated: Use the Descriptor().Number method instead.
|
|
Field int32
|
|
|
|
// Name is the fully qualified name of extension.
|
|
//
|
|
// Deprecated: Use the Descriptor().FullName method instead.
|
|
Name string
|
|
|
|
// Tag is the protobuf struct tag used in the v1 API.
|
|
//
|
|
// Deprecated: Do not use.
|
|
Tag string
|
|
|
|
// Filename is the proto filename in which the extension is defined.
|
|
//
|
|
// Deprecated: Use Descriptor().ParentFile().Path() instead.
|
|
Filename string
|
|
}
|
|
|
|
// Stages of initialization: See the ExtensionInfo.init field.
|
|
const (
|
|
extensionInfoUninitialized = 0
|
|
extensionInfoDescInit = 1
|
|
extensionInfoFullInit = 2
|
|
)
|
|
|
|
func InitExtensionInfo(xi *ExtensionInfo, xd protoreflect.ExtensionDescriptor, goType reflect.Type) {
|
|
xi.goType = goType
|
|
xi.desc = extensionTypeDescriptor{xd, xi}
|
|
xi.init = extensionInfoDescInit
|
|
}
|
|
|
|
func (xi *ExtensionInfo) New() protoreflect.Value {
|
|
return xi.lazyInit().New()
|
|
}
|
|
func (xi *ExtensionInfo) Zero() protoreflect.Value {
|
|
return xi.lazyInit().Zero()
|
|
}
|
|
func (xi *ExtensionInfo) ValueOf(v any) protoreflect.Value {
|
|
return xi.lazyInit().PBValueOf(reflect.ValueOf(v))
|
|
}
|
|
func (xi *ExtensionInfo) InterfaceOf(v protoreflect.Value) any {
|
|
return xi.lazyInit().GoValueOf(v).Interface()
|
|
}
|
|
func (xi *ExtensionInfo) IsValidValue(v protoreflect.Value) bool {
|
|
return xi.lazyInit().IsValidPB(v)
|
|
}
|
|
func (xi *ExtensionInfo) IsValidInterface(v any) bool {
|
|
return xi.lazyInit().IsValidGo(reflect.ValueOf(v))
|
|
}
|
|
func (xi *ExtensionInfo) TypeDescriptor() protoreflect.ExtensionTypeDescriptor {
|
|
if atomic.LoadUint32(&xi.init) < extensionInfoDescInit {
|
|
xi.lazyInitSlow()
|
|
}
|
|
return &xi.desc
|
|
}
|
|
|
|
func (xi *ExtensionInfo) lazyInit() Converter {
|
|
if atomic.LoadUint32(&xi.init) < extensionInfoFullInit {
|
|
xi.lazyInitSlow()
|
|
}
|
|
return xi.conv
|
|
}
|
|
|
|
func (xi *ExtensionInfo) lazyInitSlow() {
|
|
xi.mu.Lock()
|
|
defer xi.mu.Unlock()
|
|
|
|
if xi.init == extensionInfoFullInit {
|
|
return
|
|
}
|
|
defer atomic.StoreUint32(&xi.init, extensionInfoFullInit)
|
|
|
|
if xi.desc.ExtensionDescriptor == nil {
|
|
xi.initFromLegacy()
|
|
}
|
|
if !xi.desc.ExtensionDescriptor.IsPlaceholder() {
|
|
if xi.ExtensionType == nil {
|
|
xi.initToLegacy()
|
|
}
|
|
xi.conv = NewConverter(xi.goType, xi.desc.ExtensionDescriptor)
|
|
xi.info = makeExtensionFieldInfo(xi.desc.ExtensionDescriptor)
|
|
xi.info.validation = newValidationInfo(xi.desc.ExtensionDescriptor, xi.goType)
|
|
}
|
|
}
|
|
|
|
type extensionTypeDescriptor struct {
|
|
protoreflect.ExtensionDescriptor
|
|
xi *ExtensionInfo
|
|
}
|
|
|
|
func (xtd *extensionTypeDescriptor) Type() protoreflect.ExtensionType {
|
|
return xtd.xi
|
|
}
|
|
func (xtd *extensionTypeDescriptor) Descriptor() protoreflect.ExtensionDescriptor {
|
|
return xtd.ExtensionDescriptor
|
|
}
|