fix(nostr): bind cached app sessions to selected identity

Reuse only unexpired JWTs for the verified selected native public key. Invalidate stale session credentials and in-flight responses on account switching or logout, notify app state before authentication, and accept identity bootstrap only from the expected signer origin.

Preserve saved profiles and private keys. Validation: 23 focused provider tests passed, including cached-key mismatch, expiry, late response, logout and repeated-selection regressions.
This commit is contained in:
archipelago
2026-10-08 13:07:56 -04:00
parent 5828df5658
commit eef17eb79b
2 changed files with 293 additions and 57 deletions
@@ -14,6 +14,7 @@ type ProviderWindow = Window & {
}
nostr?: { getPublicKey: () => Promise<string> }
archipelagoNostr?: {
clearSession: () => void
selectIdentity: () => Promise<unknown>
getSelectedIdentity: () => { nostr_pubkey: string } | null
onIdentitySelected: (
@@ -57,6 +58,18 @@ describe('nostr-provider identity selection', () => {
delete providerWindow.ArchipelagoSurface
})
function token(pubkey: string, exp = Math.floor(Date.now() / 1000) + 3600) {
return `e30.${btoa(JSON.stringify({ sub: pubkey, exp })).replace(/=+$/, '')}.signature`
}
function choose(frame: HTMLIFrameElement, origin: string, pubkey: string, display_name?: string) {
const event = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: pubkey, display_name } }, origin,
})
Object.defineProperty(event, 'source', { value: frame.contentWindow })
window.dispatchEvent(event)
}
function loadProvider(userActivated: boolean) {
Object.defineProperty(navigator, 'userActivation', {
configurable: true,
@@ -89,14 +102,14 @@ describe('nostr-provider identity selection', () => {
)
const selected = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'abc123' } },
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: '6ca13d52ca70c883e0f0bb101e425a89e8624de51db2d2392593af6a84118090' } },
origin: signerOrigin,
})
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
window.dispatchEvent(selected)
await Promise.resolve()
await expect(publicKey).resolves.toBe('abc123')
await expect(publicKey).resolves.toBe('6ca13d52ca70c883e0f0bb101e425a89e8624de51db2d2392593af6a84118090')
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }),
signerOrigin,
@@ -120,14 +133,14 @@ describe('nostr-provider identity selection', () => {
it('keeps an eager picker choice for the app login that follows', async () => {
const { frame, postMessage, signerOrigin } = loadProvider(false)
const selected = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'fast-choice' } },
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'd930382168af7367013ad9723e7ebc093918f788abcb32ce93db245031edff0c' } },
origin: signerOrigin,
})
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
window.dispatchEvent(selected)
postMessage.mockClear()
await expect(providerWindow.nostr!.getPublicKey()).resolves.toBe('fast-choice')
await expect(providerWindow.nostr!.getPublicKey()).resolves.toBe('d930382168af7367013ad9723e7ebc093918f788abcb32ce93db245031edff0c')
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }),
signerOrigin,
@@ -137,12 +150,12 @@ describe('nostr-provider identity selection', () => {
it('does not reopen after a selected identity when activation survives account restoration', async () => {
const { frame, postMessage, signerOrigin } = loadProvider(true)
const selected = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'selected-key' } },
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: '4af900489429183b065f3cd1e46c59666642b54bed7c2a5a0c0566970992536e' } },
origin: signerOrigin,
})
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
window.dispatchEvent(selected)
await expect(providerWindow.nostr!.getPublicKey()).resolves.toBe('selected-key')
await expect(providerWindow.nostr!.getPublicKey()).resolves.toBe('4af900489429183b065f3cd1e46c59666642b54bed7c2a5a0c0566970992536e')
postMessage.mockClear()
const next = providerWindow.nostr!.getPublicKey()
expect(postMessage).not.toHaveBeenCalledWith(
@@ -150,11 +163,11 @@ describe('nostr-provider identity selection', () => {
)
const request = postMessage.mock.calls[0]![0] as { id: number }
const response = new MessageEvent('message', {
data: { type: 'nostr-response', id: request.id, result: 'selected-key' }, origin: signerOrigin,
data: { type: 'nostr-response', id: request.id, result: '4af900489429183b065f3cd1e46c59666642b54bed7c2a5a0c0566970992536e' }, origin: signerOrigin,
})
Object.defineProperty(response, 'source', { value: frame.contentWindow })
window.dispatchEvent(response)
await expect(next).resolves.toBe('selected-key')
await expect(next).resolves.toBe('4af900489429183b065f3cd1e46c59666642b54bed7c2a5a0c0566970992536e')
// Explicit account switching remains available after a successful login.
void providerWindow.archipelagoNostr!.selectIdentity()
expect(postMessage).toHaveBeenCalledWith(
@@ -162,15 +175,12 @@ describe('nostr-provider identity selection', () => {
)
})
it('does not mistake reload activation for a new login while restoring a session', () => {
it('does not let an unbound token suppress an explicit login choice', () => {
sessionStorage.setItem('nostr_token', 'test-session-hint')
const { postMessage, signerOrigin } = loadProvider(true)
void providerWindow.nostr!.getPublicKey()
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }), signerOrigin,
)
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'archipelago:signer-select-identity' }), expect.anything(),
expect.objectContaining({ type: 'archipelago:signer-select-identity' }), signerOrigin,
)
})
@@ -219,18 +229,18 @@ describe('nostr-provider identity selection', () => {
.find(message => message.type === 'nostr-request')!
expect(request).toBeDefined()
const response = new MessageEvent('message', {
data: { type: 'nostr-response', id: request.id, result: 'recreated-key' },
data: { type: 'nostr-response', id: request.id, result: 'f25efa08a1e6551d15d41622efedc40faceacc4f87ec766221613c14aa07cffb' },
origin: signerOrigin,
})
Object.defineProperty(response, 'source', { value: replacement.contentWindow })
window.dispatchEvent(response)
await expect(publicKey).resolves.toBe('recreated-key')
await expect(publicKey).resolves.toBe('f25efa08a1e6551d15d41622efedc40faceacc4f87ec766221613c14aa07cffb')
})
it('delivers an eager identity to an app listener that mounts afterward', () => {
const { frame, signerOrigin } = loadProvider(false)
const selected = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'late-listener' } },
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'c23f5e74ca130353248c66f8fdeff29bcd77515f19536c5f82c51da223f9b410' } },
origin: signerOrigin,
})
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
@@ -240,13 +250,13 @@ describe('nostr-provider identity selection', () => {
const unsubscribe = providerWindow.archipelagoNostr!.onIdentitySelected(listener)
expect(listener).toHaveBeenCalledOnce()
expect(listener).toHaveBeenCalledWith({ nostr_pubkey: 'late-listener' })
expect(listener).toHaveBeenCalledWith({ nostr_pubkey: 'c23f5e74ca130353248c66f8fdeff29bcd77515f19536c5f82c51da223f9b410' })
expect(providerWindow.archipelagoNostr!.getSelectedIdentity())
.toEqual({ nostr_pubkey: 'late-listener' })
.toEqual({ nostr_pubkey: 'c23f5e74ca130353248c66f8fdeff29bcd77515f19536c5f82c51da223f9b410' })
unsubscribe()
const changed = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'after-unsubscribe' } },
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: '7dbe01f62123a375cdfd5c92c78408db35005175e99fed1aa05d74b91df13f36' } },
origin: signerOrigin,
})
Object.defineProperty(changed, 'source', { value: frame.contentWindow })
@@ -258,13 +268,13 @@ describe('nostr-provider identity selection', () => {
vi.useFakeTimers()
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
const { postMessage, signerOrigin } = loadProvider(false)
const { frame, postMessage, signerOrigin } = loadProvider(false)
const identity = new MessageEvent('message', {
data: { type: 'archipelago:identity', nostr_pubkey: 'indeedhub-key' },
origin: window.location.origin,
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: '0f431dd8b163f611777915a6479a81f48f1d3f2e2133e6916c91290c9b352047' } },
origin: signerOrigin,
})
Object.defineProperty(identity, 'source', { value: window })
Object.defineProperty(identity, 'source', { value: frame.contentWindow })
window.dispatchEvent(identity)
await vi.advanceTimersByTimeAsync(1500)
@@ -276,7 +286,7 @@ describe('nostr-provider identity selection', () => {
expect.objectContaining({
type: 'nostr-request',
method: 'signEvent',
params: { event: expect.objectContaining({ kind: 27235, pubkey: 'indeedhub-key' }) },
params: { event: expect.objectContaining({ kind: 27235, pubkey: '0f431dd8b163f611777915a6479a81f48f1d3f2e2133e6916c91290c9b352047' }) },
}),
signerOrigin,
)
@@ -288,17 +298,17 @@ describe('nostr-provider identity selection', () => {
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ accessToken: 'real-token', refreshToken: 'refresh' }),
json: () => Promise.resolve({ accessToken: token('0f431dd8b163f611777915a6479a81f48f1d3f2e2133e6916c91290c9b352047'), refreshToken: 'refresh' }),
})
vi.stubGlobal('fetch', fetchMock)
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
const { frame, postMessage, signerOrigin } = loadProvider(false)
const identity = new MessageEvent('message', {
data: { type: 'archipelago:identity', nostr_pubkey: 'indeedhub-key' },
origin: window.location.origin,
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: '0f431dd8b163f611777915a6479a81f48f1d3f2e2133e6916c91290c9b352047' } },
origin: signerOrigin,
})
Object.defineProperty(identity, 'source', { value: window })
Object.defineProperty(identity, 'source', { value: frame.contentWindow })
window.dispatchEvent(identity)
await vi.advanceTimersByTimeAsync(1500)
@@ -313,14 +323,14 @@ describe('nostr-provider identity selection', () => {
window.dispatchEvent(show)
const signed = new MessageEvent('message', {
data: { type: 'nostr-response', id: signRequest.id, result: { id: 'signed-event' } },
data: { type: 'nostr-response', id: signRequest.id, result: { id: 'signed-event', pubkey: '0f431dd8b163f611777915a6479a81f48f1d3f2e2133e6916c91290c9b352047' } },
origin: signerOrigin,
})
Object.defineProperty(signed, 'source', { value: frame.contentWindow })
window.dispatchEvent(signed)
await vi.advanceTimersByTimeAsync(0)
expect(sessionStorage.getItem('nostr_token')).toBe('real-token')
expect(sessionStorage.getItem('nostr_token')).toBe(token('0f431dd8b163f611777915a6479a81f48f1d3f2e2133e6916c91290c9b352047'))
expect(raf).not.toHaveBeenCalled()
const hide = new MessageEvent('message', {
@@ -363,4 +373,163 @@ describe('nostr-provider identity selection', () => {
expect(postMessage.mock.calls.map(call => (call[0] as { type: string }).type))
.toEqual(['archipelago:signer-init', 'nostr-request'])
})
it.each(['expired', 'wrong-key', 'malformed'])('replaces a %s token without deleting stored accounts', async kind => {
const original = kind === 'expired' ? token('7be403e3cc82262248913b70f596a31b93f656e0da8ce5d1bf757c63ef7a8db4', 1) : kind === 'wrong-key' ? token('d9298a10d1b0735837dc4bd85dac641b0f3cef27a47e5d53a54f2f3f5b2fcffa') : 'invalid'
sessionStorage.setItem('nostr_token', original)
sessionStorage.setItem('refresh_token', 'old-refresh')
localStorage.setItem('saved-account-fixture', 'private-key-and-profile-fixture')
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
const { frame, signerOrigin, postMessage } = loadProvider(false)
choose(frame, signerOrigin, '7be403e3cc82262248913b70f596a31b93f656e0da8ce5d1bf757c63ef7a8db4')
expect(sessionStorage.getItem('nostr_token')).toBeNull()
expect(sessionStorage.getItem('refresh_token')).toBeNull()
expect(localStorage.getItem('saved-account-fixture')).toBe('private-key-and-profile-fixture')
await vi.advanceTimersByTimeAsync(1500)
expect(fetchMock).toHaveBeenCalledOnce()
expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ method: 'signEvent' }), signerOrigin)
localStorage.removeItem('saved-account-fixture')
})
it('reuses only an unexpired token matching the verified selected identity', async () => {
const saved = token('7be403e3cc82262248913b70f596a31b93f656e0da8ce5d1bf757c63ef7a8db4')
sessionStorage.setItem('nostr_token', saved)
sessionStorage.setItem('nostr_pubkey', 'stale-key')
sessionStorage.setItem('refresh_token', 'stale-refresh')
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const { frame, signerOrigin } = loadProvider(false)
choose(frame, signerOrigin, '7be403e3cc82262248913b70f596a31b93f656e0da8ce5d1bf757c63ef7a8db4', 'My native profile')
await vi.advanceTimersByTimeAsync(1600)
expect(fetchMock).not.toHaveBeenCalled()
expect(sessionStorage.getItem('nostr_token')).toBe(saved)
expect(sessionStorage.getItem('nostr_pubkey')).toBe('7be403e3cc82262248913b70f596a31b93f656e0da8ce5d1bf757c63ef7a8db4')
expect(sessionStorage.getItem('refresh_token')).toBeNull()
expect(providerWindow.archipelagoNostr!.getSelectedIdentity()).toEqual({ nostr_pubkey: '7be403e3cc82262248913b70f596a31b93f656e0da8ce5d1bf757c63ef7a8db4', display_name: 'My native profile' })
})
it('ignores self-forged identity messages and signer messages from another origin', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const { frame } = loadProvider(false)
choose(frame, 'https://untrusted.example', 'ccdd35168ab474fa5764a526cfb83621351e23682c5075b2e18d56bddf96aa30')
const event = new MessageEvent('message', { data: { type: 'archipelago:identity', nostr_pubkey: 'ccdd35168ab474fa5764a526cfb83621351e23682c5075b2e18d56bddf96aa30' }, origin: location.origin })
Object.defineProperty(event, 'source', { value: window })
window.dispatchEvent(event)
await vi.advanceTimersByTimeAsync(1600)
expect(providerWindow.archipelagoNostr!.getSelectedIdentity()).toBeNull()
expect(fetchMock).not.toHaveBeenCalled()
})
it.each(['switch', 'logout'])('does not store an in-flight old session after %s', async action => {
let complete!: (response: unknown) => void
const fetchMock = vi.fn().mockResolvedValueOnce({ ok: true }).mockImplementationOnce(() => new Promise(resolve => { complete = resolve }))
vi.stubGlobal('fetch', fetchMock)
const { frame, signerOrigin, postMessage } = loadProvider(false)
choose(frame, signerOrigin, 'cba06b5736faf67e54b07b561eae94395e774c517a7d910a54369e1263ccfbd4')
await vi.advanceTimersByTimeAsync(1500)
const request = postMessage.mock.calls.map(c => c[0]).find(v => v.method === 'signEvent')
const signed = new MessageEvent('message', { data: { type: 'nostr-response', id: request.id, result: { pubkey: 'cba06b5736faf67e54b07b561eae94395e774c517a7d910a54369e1263ccfbd4' } }, origin: signerOrigin })
Object.defineProperty(signed, 'source', { value: frame.contentWindow })
window.dispatchEvent(signed)
await vi.advanceTimersByTimeAsync(0)
if (action === 'switch') choose(frame, signerOrigin, '11507a0e2f5e69d5dfa40a62a1bd7b6ee57e6bcd85c67c9b8431b36fff21c437')
else providerWindow.archipelagoNostr!.clearSession()
complete({ ok: true, json: async () => ({ accessToken: token('cba06b5736faf67e54b07b561eae94395e774c517a7d910a54369e1263ccfbd4'), refreshToken: 'old-refresh' }) })
await vi.advanceTimersByTimeAsync(0)
expect(sessionStorage.getItem('nostr_token')).toBeNull()
expect(sessionStorage.getItem('refresh_token')).toBeNull()
})
it('announces a verified switch before clearing the old session and exposes only public metadata', () => {
const oldKey = 'a'.repeat(64), nextKey = 'b'.repeat(64)
sessionStorage.setItem('nostr_token', token(oldKey))
const { frame, signerOrigin } = loadProvider(false)
const changes: unknown[] = []
window.addEventListener('archipelago:identity-changing', event => {
changes.push({ detail: (event as CustomEvent).detail, token: sessionStorage.getItem('nostr_token'), selected: providerWindow.archipelagoNostr!.getSelectedIdentity() })
})
choose(frame, signerOrigin, nextKey, 'Chosen profile')
expect(changes).toEqual([{ detail: { nostr_pubkey: nextKey, previous_nostr_pubkey: null }, token: token(oldKey), selected: { nostr_pubkey: nextKey, display_name: 'Chosen profile' } }])
expect(sessionStorage.getItem('nostr_token')).toBeNull()
})
it('rejects a malformed native identity without exposing it or starting authentication', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const { frame, signerOrigin } = loadProvider(false)
choose(frame, signerOrigin, 'not-a-native-public-key')
await vi.advanceTimersByTimeAsync(1600)
expect(providerWindow.archipelagoNostr!.getSelectedIdentity()).toBeNull()
expect(fetchMock).not.toHaveBeenCalled()
})
it('clears a now-expired token on repeated selection of the same identity', () => {
const key = 'a'.repeat(64)
const { frame, signerOrigin } = loadProvider(false)
choose(frame, signerOrigin, key)
sessionStorage.setItem('nostr_token', token(key, 1))
const notify = vi.fn()
window.addEventListener('archipelago:identity-changing', notify)
choose(frame, signerOrigin, key)
expect(notify).toHaveBeenCalledOnce()
expect(sessionStorage.getItem('nostr_token')).toBeNull()
})
it('keeps logout quiet and permits a subsequent explicit identity choice', async () => {
const key = 'a'.repeat(64)
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
const { frame, signerOrigin, postMessage } = loadProvider(false)
choose(frame, signerOrigin, key)
providerWindow.archipelagoNostr!.clearSession()
await vi.advanceTimersByTimeAsync(1600)
expect(fetchMock).not.toHaveBeenCalled()
expect(providerWindow.archipelagoNostr!.getSelectedIdentity()).toBeNull()
const selection = providerWindow.archipelagoNostr!.selectIdentity()
expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'archipelago:signer-select-identity', force: true }), signerOrigin)
choose(frame, signerOrigin, key)
await expect(selection).resolves.toEqual({ nostr_pubkey: key })
await vi.advanceTimersByTimeAsync(1500)
expect(fetchMock).toHaveBeenCalledOnce()
})
it('rejects a session response bound to another key after valid signing', async () => {
const key = 'a'.repeat(64)
const fetchMock = vi.fn().mockResolvedValueOnce({ ok: true }).mockResolvedValueOnce({ ok: true, json: async () => ({ accessToken: token('b'.repeat(64)) }) })
vi.stubGlobal('fetch', fetchMock)
const { frame, signerOrigin, postMessage } = loadProvider(false)
choose(frame, signerOrigin, key)
await vi.advanceTimersByTimeAsync(1500)
const request = postMessage.mock.calls.map(c => c[0]).find(v => v.method === 'signEvent')
const signed = new MessageEvent('message', { data: { type: 'nostr-response', id: request.id, result: { pubkey: key } }, origin: signerOrigin })
Object.defineProperty(signed, 'source', { value: frame.contentWindow })
window.dispatchEvent(signed)
await vi.advanceTimersByTimeAsync(0)
expect(sessionStorage.getItem('nostr_token')).toBeNull()
})
it('does not reset an in-flight auth when the same identity is announced again', async () => {
const key = 'a'.repeat(64)
let complete!: (response: unknown) => void
const fetchMock = vi.fn().mockResolvedValueOnce({ ok: true }).mockImplementationOnce(() => new Promise(resolve => { complete = resolve }))
vi.stubGlobal('fetch', fetchMock)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
const { frame, signerOrigin, postMessage } = loadProvider(false)
choose(frame, signerOrigin, key)
await vi.advanceTimersByTimeAsync(1500)
const request = postMessage.mock.calls.map(c => c[0]).find(v => v.method === 'signEvent')
const signed = new MessageEvent('message', { data: { type: 'nostr-response', id: request.id, result: { pubkey: key } }, origin: signerOrigin })
Object.defineProperty(signed, 'source', { value: frame.contentWindow })
window.dispatchEvent(signed)
await vi.advanceTimersByTimeAsync(0)
const notify = vi.fn()
window.addEventListener('archipelago:identity-changing', notify)
choose(frame, signerOrigin, key)
expect(notify).not.toHaveBeenCalled()
complete({ ok: true, json: async () => ({ accessToken: token(key) }) })
await vi.advanceTimersByTimeAsync(0)
expect(sessionStorage.getItem('nostr_token')).toBe(token(key))
})
})