diff --git a/src/lib/DivineJWTSigner.test.ts b/src/lib/DivineJWTSigner.test.ts index 7c0d3a1f..da1dac27 100644 --- a/src/lib/DivineJWTSigner.test.ts +++ b/src/lib/DivineJWTSigner.test.ts @@ -131,6 +131,56 @@ describe('DivineJWTSigner', () => { ); }); + it('self-heals the cached pubkey when the remote signer returns a different pubkey', async () => { + const stalePubkey = 'a'.repeat(64); + const freshPubkey = 'b'.repeat(64); + const unsignedEvent = { + kind: 1, + content: 'pubkey rotation', + tags: [], + created_at: 1234567890, + }; + const signedEvent = { + ...unsignedEvent, + id: 'event-id-rotated', + pubkey: freshPubkey, // remote signer used a different key than we cached + sig: 'sig-rotated', + }; + + mockFetch + .mockResolvedValueOnce({ + json: async () => ({ result: stalePubkey }), + }) + .mockResolvedValueOnce({ + json: async () => ({ result: signedEvent }), + }) + .mockResolvedValueOnce({ + json: async () => ({ result: signedEvent }), + }); + + const signer = new DivineJWTSigner({ token: 'rotation-token' }); + + // First sign uses the stale cached pubkey on the unsigned event, + // but returns a signed event whose pubkey is the fresh one. + await expect(signer.signEvent(unsignedEvent)).resolves.toEqual(signedEvent); + + // The cache must now reflect the fresh pubkey, even though we never + // re-fetched it from get_public_key. + await expect(signer.getPublicKey()).resolves.toBe(freshPubkey); + + // Subsequent signEvent calls put the corrected pubkey on the unsigned event. + await signer.signEvent(unsignedEvent); + expect(mockFetch).toHaveBeenLastCalledWith( + `${DIVINE_LOGIN_ORIGIN}/api/nostr`, + expect.objectContaining({ + body: JSON.stringify({ + method: 'sign_event', + params: [{ ...unsignedEvent, pubkey: freshPubkey }], + }), + }) + ); + }); + it('routes nip04 and nip44 encryption through the same RPC endpoint', async () => { mockFetch .mockResolvedValueOnce({ diff --git a/src/lib/DivineJWTSigner.ts b/src/lib/DivineJWTSigner.ts index 6d54cb4b..ed59deeb 100644 --- a/src/lib/DivineJWTSigner.ts +++ b/src/lib/DivineJWTSigner.ts @@ -129,6 +129,27 @@ export class DivineJWTSigner implements NostrSigner { throw new Error('Invalid response: missing signed event'); } + // Self-heal a stale pubkey cache. If the remote signer signed with a key + // whose pubkey differs from what we cached and put on the unsigned event, + // the returned event is internally consistent (signed against its own + // `pubkey` field) but our cache is wrong. Trust the returned event and + // update the cache so subsequent signEvent calls put the correct pubkey + // on the unsigned event. Without this, every subsequent NIP-98 / Blossom + // auth event would carry a pubkey that doesn't match its signature, and + // every viewer auth check downstream (divine-blossom, NIP-98 consumers) + // would 401 with "Invalid signature" until the signer is recreated. + if (signedEvent.pubkey && signedEvent.pubkey !== this.cachedPubkey) { + console.warn( + '[DivineJWTSigner] ⚠️ pubkey mismatch — cached', + this.cachedPubkey, + 'but signed event reports', + signedEvent.pubkey, + '— updating cache', + ); + this.cachedPubkey = signedEvent.pubkey; + DivineJWTSigner.sharedPubkeys.set(this.pubkeyCacheKey, signedEvent.pubkey); + } + console.log('[DivineJWTSigner] ✅ Event signed:', signedEvent.id); return signedEvent; }