feat(backend): per-Reader userscript credential with UI install and rotation (#24)
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
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
package token
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTokenDeterministicPerReaderAndEpoch(t *testing.T) {
|
||||
key := []byte("deployment-secret")
|
||||
a := Token(key, "reader-1", 0)
|
||||
b := Token(key, "reader-1", 0)
|
||||
if a != b {
|
||||
t.Fatal("same (reader, epoch) derived different credentials")
|
||||
}
|
||||
if a == Token(key, "reader-2", 0) {
|
||||
t.Fatal("different readers derived the same credential")
|
||||
}
|
||||
if a == Token(key, "reader-1", 1) {
|
||||
t.Fatal("rotation epoch derived the same credential")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenChangesWithSecret(t *testing.T) {
|
||||
a := Token([]byte("key-1"), "reader-1", 0)
|
||||
b := Token([]byte("key-2"), "reader-1", 0)
|
||||
if a == b {
|
||||
t.Fatal("different secrets derived the same credential")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenFormat(t *testing.T) {
|
||||
cred := Token([]byte("key"), "reader-1", 0)
|
||||
// 32 bytes of HMAC-SHA256, hex-encoded: the length the install URL and
|
||||
// the committed placeholder both assume.
|
||||
if len(cred) != 64 {
|
||||
t.Fatalf("credential length = %d, want 64", len(cred))
|
||||
}
|
||||
for _, c := range cred {
|
||||
if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
|
||||
t.Fatalf("credential contains non-hex byte %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashIsSha256OfCredential(t *testing.T) {
|
||||
cred := Token([]byte("key"), "reader-1", 0)
|
||||
got := Hash(cred)
|
||||
want := sha256.Sum256([]byte(cred))
|
||||
if !bytes.Equal(got[:], want[:]) {
|
||||
t.Fatal("Hash is not the SHA-256 of the credential")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user