hush/vendor/github.com/munnerz/goautoneg/autoneg.go
jx12n 4d9a26498e hush: one-time secret links the server cannot read
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.
2026-09-03 00:08:38 -06:00

190 lines
4.9 KiB
Go

/*
HTTP Content-Type Autonegotiation.
The functions in this package implement the behaviour specified in
http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html
Copyright (c) 2011, Open Knowledge Foundation Ltd.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
Neither the name of the Open Knowledge Foundation Ltd. nor the
names of its contributors may be used to endorse or promote
products derived from this software without specific prior written
permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package goautoneg
import (
"sort"
"strconv"
"strings"
)
// Structure to represent a clause in an HTTP Accept Header
type Accept struct {
Type, SubType string
Q float64
Params map[string]string
}
// acceptSlice is defined to implement sort interface.
type acceptSlice []Accept
func (slice acceptSlice) Len() int {
return len(slice)
}
func (slice acceptSlice) Less(i, j int) bool {
ai, aj := slice[i], slice[j]
if ai.Q > aj.Q {
return true
}
if ai.Type != "*" && aj.Type == "*" {
return true
}
if ai.SubType != "*" && aj.SubType == "*" {
return true
}
return false
}
func (slice acceptSlice) Swap(i, j int) {
slice[i], slice[j] = slice[j], slice[i]
}
func stringTrimSpaceCutset(r rune) bool {
return r == ' '
}
func nextSplitElement(s, sep string) (item string, remaining string) {
if index := strings.Index(s, sep); index != -1 {
return s[:index], s[index+1:]
}
return s, ""
}
// Parse an Accept Header string returning a sorted list
// of clauses
func ParseAccept(header string) acceptSlice {
partsCount := 0
remaining := header
for len(remaining) > 0 {
partsCount++
_, remaining = nextSplitElement(remaining, ",")
}
accept := make(acceptSlice, 0, partsCount)
remaining = header
var part string
for len(remaining) > 0 {
part, remaining = nextSplitElement(remaining, ",")
part = strings.TrimFunc(part, stringTrimSpaceCutset)
a := Accept{
Q: 1.0,
}
sp, remainingPart := nextSplitElement(part, ";")
sp0, spRemaining := nextSplitElement(sp, "/")
a.Type = strings.TrimFunc(sp0, stringTrimSpaceCutset)
switch {
case len(spRemaining) == 0:
if a.Type == "*" {
a.SubType = "*"
} else {
continue
}
default:
var sp1 string
sp1, spRemaining = nextSplitElement(spRemaining, "/")
if len(spRemaining) > 0 {
continue
}
a.SubType = strings.TrimFunc(sp1, stringTrimSpaceCutset)
}
if len(remainingPart) == 0 {
accept = append(accept, a)
continue
}
a.Params = make(map[string]string)
for len(remainingPart) > 0 {
sp, remainingPart = nextSplitElement(remainingPart, ";")
sp0, spRemaining = nextSplitElement(sp, "=")
if len(spRemaining) == 0 {
continue
}
var sp1 string
sp1, spRemaining = nextSplitElement(spRemaining, "=")
if len(spRemaining) != 0 {
continue
}
token := strings.TrimFunc(sp0, stringTrimSpaceCutset)
if token == "q" {
a.Q, _ = strconv.ParseFloat(sp1, 32)
} else {
a.Params[token] = strings.TrimFunc(sp1, stringTrimSpaceCutset)
}
}
accept = append(accept, a)
}
sort.Sort(accept)
return accept
}
// Negotiate the most appropriate content_type given the accept header
// and a list of alternatives.
func Negotiate(header string, alternatives []string) (content_type string) {
asp := make([][]string, 0, len(alternatives))
for _, ctype := range alternatives {
asp = append(asp, strings.SplitN(ctype, "/", 2))
}
for _, clause := range ParseAccept(header) {
for i, ctsp := range asp {
if clause.Type == ctsp[0] && clause.SubType == ctsp[1] {
content_type = alternatives[i]
return
}
if clause.Type == ctsp[0] && clause.SubType == "*" {
content_type = alternatives[i]
return
}
if clause.Type == "*" && clause.SubType == "*" {
content_type = alternatives[i]
return
}
}
}
return
}