// secretvault.go — carry a freshly minted secret from the POST that
// made it to the GET that shows it, without putting it in a URL.
//
// Four secrets are minted from the settings page and shown exactly
// once: a webhook signing key (on create and on rotate), a CI reporter
// token, and a worker token. None is stored readable, so the POST
// cannot simply redirect and let the next page read it back — which is
// why all four used to ride home in the query string.
//
// A query string is the wrong carrier for a credential. It lands in the
// reader's browser history, in the Referer of anything the page then
// loads, and in the access log of every proxy between here and them —
// this forge sits behind a CDN, so "shown once" meant "written down
// three times". Two worker tokens were revoked over it rather than
// trusted; nobody went looking in the edge logs to find out.
//
// So the secret stays here for two minutes under a single-use nonce,
// and the nonce is what travels. A nonce in a log is worth nothing: by
// the time anyone reads it, the page that consumed it has already taken
// the value out.
//
// Not setFlash, deliberately, though that is this package's carrier for
// a one-shot message and does carry the invite and password-reset links
// today. A flash is strictly better than a query string — HttpOnly,
// Secure, SameSite=Lax, and never in a URL, so it leaves no access-log
// line, no history entry and no Referer. What it does is repeat: the
// cookie is Path="/" with a 30-second life, so until it is spent the
// browser sends it back on every request to any path, and a credential
// in one crosses the edge many times instead of once. A notice can
// afford that; a bearer token should not have to. (The invite and password-reset links were
// moved onto this for that reason; the flash still carries the OUTCOME
// beside them, which is a notice and not a credential.)
package web
import (
"crypto/rand"
"encoding/hex"
"sync"
"time"
)
// secretTTL is how long a minted secret waits to be collected. Long
// enough for a redirect the reader is already following, short enough
// that an abandoned mint is not sitting in memory minutes later.
const secretTTL = 2 * time.Minute
type vaultedSecret struct {
secret string
at time.Time
}
// secretVault holds minted secrets between the POST and the GET.
//
// In memory on purpose. A secret that survives a restart is a secret
// that outlived the request it was minted for; losing them on restart
// costs a re-mint and nothing else.
type secretVault struct {
mu sync.Mutex
m map[string]vaultedSecret
}
func newSecretVault() *secretVault {
return &secretVault{m: map[string]vaultedSecret{}}
}
// mintedSecrets is the process-wide vault. One forge, one process.
var mintedSecrets = newSecretVault()
// stash files a secret and returns the nonce that stands for it. An
// empty secret gets an empty nonce: nothing to show means nothing to
// put in the URL, rather than a nonce that resolves to "".
func (v *secretVault) stash(secret string) string {
if secret == "" {
return ""
}
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
// Without a nonce there is no safe carrier, and the caller's
// alternative is the query string this exists to avoid. Show
// nothing: the secret is lost, which costs a re-mint, and the
// reader is told to mint again rather than handed a credential
// through a channel that logs it.
return ""
}
nonce := hex.EncodeToString(b)
v.mu.Lock()
defer v.mu.Unlock()
v.sweepLocked()
v.m[nonce] = vaultedSecret{secret: secret, at: time.Now()}
return nonce
}
// take returns the secret for a nonce and forgets it. Single use: a
// reload of the page that showed it gets nothing, which is what "shown
// once" has always claimed and now actually means.
func (v *secretVault) take(nonce string) string {
if nonce == "" {
return ""
}
v.mu.Lock()
defer v.mu.Unlock()
v.sweepLocked()
e, ok := v.m[nonce]
if !ok {
return ""
}
delete(v.m, nonce)
if time.Since(e.at) > secretTTL {
return ""
}
return e.secret
}
// sweepLocked drops what nobody collected. Called on both paths rather
// than on a timer: the map only grows when someone mints, so the mint
// is the moment worth paying for the walk.
func (v *secretVault) sweepLocked() {
for k, e := range v.m {
if time.Since(e.at) > secretTTL {
delete(v.m, k)
}
}
}