Skip to content

fix(signer): self-heal DivineJWTSigner pubkey cache on remote rotation#283

Open
rabble wants to merge 1 commit into
mainfrom
fix/jwt-signer-self-heal-pubkey
Open

fix(signer): self-heal DivineJWTSigner pubkey cache on remote rotation#283
rabble wants to merge 1 commit into
mainfrom
fix/jwt-signer-self-heal-pubkey

Conversation

@rabble

@rabble rabble commented Apr 28, 2026

Copy link
Copy Markdown
Member

Summary

Defensive client-side guard for the second half of the divine-blossom 401 saga (PR #281 was the first half — gating auth headers on `videoData.ageRestricted` so a broken signer didn't poison public blobs). This PR addresses the age-restricted case where the hosted signer (keycast at `login.divine.video/api/nostr`) returns a signed event whose `pubkey` field disagrees with what we cached and put on the unsigned event.

When that happens today, the returned event is internally consistent (sig was made by some key K, event reports `pubkey = K`) but our cache still holds the stale value. Subsequent `signEvent` calls put the stale pubkey on the unsigned event, the remote signer signs with K again, and the returned event has `pubkey: stale` + `sig` made by K. Schnorr verify against `stale` fails → divine-blossom 401s with "Invalid signature" on every age-restricted blob.

Self-heal: after each successful `signEvent`, if `signedEvent.pubkey !== this.cachedPubkey`, update the per-instance and `sharedPubkeys` static cache to the returned value and `console.warn` the mismatch (so we get telemetry on whether this is firing in the wild).

What this does NOT fix

The underlying remote-signer disagreement. If keycast really is signing with a key that doesn't match its own `get_public_key` response, that's a server bug and needs a corresponding patch in `keycast/core/src/signing_session.rs` — either reject calls where `unsigned.pubkey != self.keys.public_key()`, or canonicalize `unsigned.pubkey = self.keys.public_key()` before signing. Filing that separately.

Test plan

  • `npx vitest run` — 702/702 pass (one new test for the self-heal)
  • `npx tsc --noEmit` clean
  • Watch production logs for `[DivineJWTSigner] ⚠️ pubkey mismatch` warnings — non-zero count confirms the rotation hypothesis was real
  • Verify age-restricted videos play for users on JWT signer who hit this case

🤖 Generated with Claude Code

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Apr 28, 2026

Copy link
Copy Markdown

Deploying divine-web with  Cloudflare Pages  Cloudflare Pages

Latest commit: 9129a7d
Status: ✅  Deploy successful!
Preview URL: https://b1876a48.divine-web.pages.dev
Branch Preview URL: https://fix-jwt-signer-self-heal-pub.divine-web.pages.dev

View logs

@github-actions

github-actions Bot commented Apr 28, 2026

Copy link
Copy Markdown

🚀 Preview Deployment

Property Value
Preview URL https://217a06ba.divine-web-fm8.pages.dev
Commit 9129a7d
Branch fix/jwt-signer-self-heal-pubkey

@rabble

rabble commented Jun 5, 2026

Copy link
Copy Markdown
Member Author

Self-review note: I reviewed the signer pubkey self-heal diff and current checks. I did not find a blocker, but this still needs non-author approval because the active GitHub account is the PR author.

When the hosted signer (keycast / login.divine.video) returns a signed
event whose pubkey differs from the one we put on the unsigned event,
trust the returned pubkey, update the per-instance and shared cache,
and warn loudly. Without this, every subsequent NIP-98 / Blossom auth
event puts a stale pubkey on the wire — divine-blossom recomputes the
event id against that stale pubkey, the schnorr verify fails against
the actually-correct sig, and the viewer 401s with "Invalid signature"
on every age-restricted blob until the signer is recreated.

This is a defensive client-side guard. The deeper bug — the remote
signer disagreeing with its own get_public_key() — needs a separate
fix on keycast (either reject on mismatch or canonicalize unsigned
pubkey to keys.public_key() before signing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rabble rabble force-pushed the fix/jwt-signer-self-heal-pubkey branch from dab1088 to 9129a7d Compare June 5, 2026 11:02
@rabble

rabble commented Jun 11, 2026

Copy link
Copy Markdown
Member Author

Review verdict: APPROVE (self-authored PR — GitHub blocks formal self-approval, so posting as a comment)

LGTM — small, scoped, and the test is exactly the regression coverage this needs (asserts both the healed cache and the corrected pubkey on the next sign_event request body). CI is green and the branch is mergeable despite being from late April.

Two non-blocking thoughts before/after merge:

  1. Split-brain identity: useCurrentUser resolves user.pubkey once into jwtResolution and never re-reads the signer cache. After a self-heal, signatures are valid but the app still displays and queries the stale pubkey (profile, feeds, hex-pubkey API calls), and new events attribute to a different identity than the UI shows. Fine for a defensive guard, but let's file a follow-up to surface the mismatch to useCurrentUser (or force re-auth).
  2. Telemetry: the console.warn only reaches us if console capture is wired into Sentry — worth double-checking, since the test plan's "watch production logs" step depends on it.

Agreed the real fix belongs in keycast (signing_session.rs rejecting/canonicalizing unsigned.pubkey); glad that's tracked separately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant