8f752ed86b
Each Reader's userscript credential is derived from TOKEN_KEY, their Discord id and a token epoch (HMAC-SHA256, hex); only its SHA-256 sits in readers.token_sha256, so install URLs survive restarts while a database leak yields nothing but hashes. One credential authenticates the script download path and the API bearer header. - internal/token: derivation + hashing; migration 0006 adds token_epoch - seed refreshes the owner's epoch-0 hash only before first rotation - httpmw.Auth resolves the acting Reader from the credential hash and stashes it in the request context; the retired API_TOKEN resolves to the owner until API_TOKEN_GRACE_UNTIL, logged per use, on both the bearer and script-download paths - userscript handler renders the bindmounted file with the resolved Reader's credential substituted for __API_TOKEN__; a legacy-path request during grace serves the derived credential, so devices self-migrate on their next update poll - web UI: Userscripts panel with session-gated install endpoints that render the script directly (credential never in markup, address bar or a redirect) and confirm-gated rotation; atomic epoch bump + hash rewrite in the store - both userscripts carry __API_TOKEN__ placeholders; the committed global-token literal is removed (rotating at deploy retires it for real — it survives in git history) - env: TOKEN_KEY required, API_TOKEN/API_TOKEN_GRACE_UNTIL retire the legacy credential; docs and compose updated
38 lines
1.5 KiB
Go
38 lines
1.5 KiB
Go
package token
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"strconv"
|
|
)
|
|
|
|
// Token derives one Reader's userscript credential from the deployment
|
|
// secret, the Reader's Discord id and their token epoch.
|
|
//
|
|
// The credential is deterministic rather than stored random because the
|
|
// server must be able to rebuild the install URL after a restart while the
|
|
// database holds only hashes: a random token with no plaintext copy anywhere
|
|
// would be unreconstructible, and keeping plaintext in memory would break
|
|
// every install link on restart. HMAC output is high-entropy, indistinguishable
|
|
// from random to anyone without the secret, and changes whenever the epoch
|
|
// does — which is what rotation is. The stored form is Hash of this value,
|
|
// so a database leak yields nothing but hashes of unguessable strings.
|
|
func Token(key []byte, discordID string, epoch int64) string {
|
|
mac := hmac.New(sha256.New, key)
|
|
// The separator is unambiguous: discord ids are decimal snowflakes and
|
|
// epochs are plain integers, so no two (id, epoch) pairs can collide.
|
|
mac.Write([]byte(discordID))
|
|
mac.Write([]byte{0})
|
|
mac.Write([]byte(strconv.FormatInt(epoch, 10)))
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
// Hash is the SHA-256 of a credential — the only form that ever touches the
|
|
// database (readers.token_sha256). SHA-256 rather than a password hash is
|
|
// deliberate: these are unguessable values with nothing to brute-force, so a
|
|
// slow hash would only add per-request cost.
|
|
func Hash(cred string) [32]byte {
|
|
return sha256.Sum256([]byte(cred))
|
|
}
|