Closes #24. Child of #18; based on current main (includes Postgres, Reader table, Discord OAuth).
## What
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` (new `token_epoch` column, migration 0006). One credential authenticates the script download path and the API bearer header.
- `internal/token`: derivation + hashing; the seed refreshes the owner's epoch-0 hash only before first rotation, so a restart can never resurrect a rotated-away credential
- `httpmw.Auth`/`ResolveReader`: acting Reader resolved from the credential hash, stashed in request context; the retired global `API_TOKEN` resolves to the owner until `API_TOKEN_GRACE_UNTIL` (enforced in code, 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 installed devices self-migrate on their next update poll
- Web UI: "Userscripts" panel — session-gated install endpoints render the script directly (credential never in markup, address bar, or a redirect), confirm-gated rotation with an atomic epoch bump + hash rewrite and a reinstall warning
- Both userscripts carry `__API_TOKEN__` placeholders; the committed global-token literal is removed
## Design note
Credentials are derived rather than stored-random because the server must rebuild install URLs after restarts while the DB holds only hashes. HMAC output is high-entropy and unbrute-forceable; the AC's intent (unguessable, DB-leak-proof) is met.
## Deploy (also in DEPLOY.md)
1. Add `TOKEN_KEY` (`openssl rand -hex 32`) — required; changing it later invalidates every credential.
2. Keep `API_TOKEN` + set `API_TOKEN_GRACE_UNTIL` for the 14-day window.
3. After deploy, sign in → Userscripts → reinstall both scripts on every device. This also retires the old global credential for real — its literal survives in git history (present since 0ef5286), so rotation is what kills it.
## Verification
- Full Go suite green against real Postgres per test; userscript JS suite 45/45
- New router-level tests: per-Reader isolation (read/write/delete), grace expiry on bearer + script path, self-migrating legacy path, install serving, rotation (old cred 401/404, new cred works, install renders new credential), app page leaks no credential
- Store tests: hash lookup, token info, atomic rotation with stale-epoch rejection, rotation survives restart
- Live smoke of the built binary: grace acceptance logged, derived auth, substitution, restart resilience, stored hash = SHA-256 of derived credential
Reviewed-on: #32
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #32.
This commit is contained in:
@@ -75,6 +75,92 @@ func TestOpenIsIdempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The hash lookup is the whole authentication path: the store resolves a
|
||||
// Reader from the SHA-256 of their presented credential, and nothing else.
|
||||
func TestReaderIDForTokenHash(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ownerHash := sha256.Sum256([]byte("owner-token-hash"))
|
||||
|
||||
id, ok, err := store.ReaderIDForTokenHash(ownerHash)
|
||||
if err != nil {
|
||||
t.Fatalf("ReaderIDForTokenHash: %v", err)
|
||||
}
|
||||
if !ok || id != store.OwnerID() {
|
||||
t.Fatalf("owner lookup = (%d, %v), want (%d, true)", id, ok, store.OwnerID())
|
||||
}
|
||||
|
||||
if _, ok, err := store.ReaderIDForTokenHash(sha256.Sum256([]byte("nope"))); err != nil {
|
||||
t.Fatalf("miss: %v", err)
|
||||
} else if ok {
|
||||
t.Fatal("unknown hash resolved to a Reader")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaderTokenInfo(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
discordID, epoch, err := store.ReaderTokenInfo(store.OwnerID())
|
||||
if err != nil {
|
||||
t.Fatalf("ReaderTokenInfo: %v", err)
|
||||
}
|
||||
if discordID != testOwner.DiscordID || epoch != 0 {
|
||||
t.Fatalf("ReaderTokenInfo = (%q, %d), want (%q, 0)", discordID, epoch, testOwner.DiscordID)
|
||||
}
|
||||
}
|
||||
|
||||
// Rotation swaps the stored hash and bumps the epoch in one step, and the
|
||||
// seed must not undo it: a restart re-runs seedOwner, which refreshes the
|
||||
// epoch-0 hash only while the row has never been rotated.
|
||||
func TestRotateTokenInvalidatesOldAndSurvivesRestart(t *testing.T) {
|
||||
url := pgtest.URL(t)
|
||||
store, err := Open(url, testOwner)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
|
||||
oldHash := sha256.Sum256([]byte("owner-token-hash"))
|
||||
newHash := sha256.Sum256([]byte("rotated-token-hash"))
|
||||
if err := store.RotateToken(store.OwnerID(), 0, newHash); err != nil {
|
||||
t.Fatalf("RotateToken: %v", err)
|
||||
}
|
||||
// A second rotation against the stale epoch is refused: the stored hash
|
||||
// must never describe a different epoch than the column says.
|
||||
if err := store.RotateToken(store.OwnerID(), 0, sha256.Sum256([]byte("third-hash"))); err == nil {
|
||||
t.Fatal("stale-epoch rotation succeeded, want error")
|
||||
}
|
||||
if _, ok, err := store.ReaderIDForTokenHash(oldHash); err != nil {
|
||||
t.Fatalf("old lookup: %v", err)
|
||||
} else if ok {
|
||||
t.Fatal("old hash still resolves after rotation")
|
||||
}
|
||||
if id, ok, err := store.ReaderIDForTokenHash(newHash); err != nil {
|
||||
t.Fatalf("new lookup: %v", err)
|
||||
} else if !ok || id != store.OwnerID() {
|
||||
t.Fatalf("new hash resolved to (%d, %v), want owner", id, ok)
|
||||
}
|
||||
if _, epoch, err := store.ReaderTokenInfo(store.OwnerID()); err != nil {
|
||||
t.Fatalf("ReaderTokenInfo: %v", err)
|
||||
} else if epoch != 1 {
|
||||
t.Fatalf("epoch = %d after rotation, want 1", epoch)
|
||||
}
|
||||
store.Close()
|
||||
|
||||
reopened, err := Open(url, testOwner)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { reopened.Close() })
|
||||
if _, ok, err := reopened.ReaderIDForTokenHash(oldHash); err != nil {
|
||||
t.Fatalf("old lookup after reopen: %v", err)
|
||||
} else if ok {
|
||||
t.Fatal("restart resurrected the pre-rotation hash")
|
||||
}
|
||||
if _, ok, err := reopened.ReaderIDForTokenHash(newHash); err != nil {
|
||||
t.Fatalf("new lookup after reopen: %v", err)
|
||||
} else if !ok {
|
||||
t.Fatal("restart dropped the rotated hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreGet(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
if _, err := store.Upsert(store.OwnerID(), Bookmark{
|
||||
|
||||
Reference in New Issue
Block a user