Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/lib/DivineJWTSigner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
21 changes: 21 additions & 0 deletions src/lib/DivineJWTSigner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading