From 2efc884e01be62e4f8b791a9f9ca407f09854d99 Mon Sep 17 00:00:00 2001 From: codytseng Date: Sat, 4 Apr 2026 23:52:49 +0800 Subject: [PATCH] feat: migrate NIP-51 list encryption from NIP-04 to NIP-44 NIP-04 encryption is deprecated due to security vulnerabilities. This migrates MuteList (kind 10000) and PinnedUsers (kind 10010) private entries to use NIP-44 encryption, with backward compatibility for reading existing NIP-04 encrypted content. When NIP-04 content is detected, it is automatically re-encrypted with NIP-44 and republished to gradually migrate users. Co-Authored-By: captain-stacks <201298974+captain-stacks@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) --- src/providers/MuteListProvider.tsx | 79 ++++++++++++++----- src/providers/NostrProvider/bunker.signer.ts | 14 ++++ src/providers/NostrProvider/index.tsx | 12 +++ src/providers/NostrProvider/nip-07.signer.ts | 20 +++++ .../NostrProvider/nostrConnection.signer.ts | 14 ++++ src/providers/NostrProvider/npub.signer.ts | 8 ++ src/providers/NostrProvider/nsec.signer.ts | 17 ++++ src/providers/PinnedUsersProvider.tsx | 53 ++++++++++--- src/types/index.d.ts | 6 ++ 9 files changed, 191 insertions(+), 32 deletions(-) diff --git a/src/providers/MuteListProvider.tsx b/src/providers/MuteListProvider.tsx index 0daab73..dc8428b 100644 --- a/src/providers/MuteListProvider.tsx +++ b/src/providers/MuteListProvider.tsx @@ -41,7 +41,8 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { publish, updateMuteListEvent, nip04Decrypt, - nip04Encrypt + nip44Encrypt, + nip44Decrypt } = useNostr() const [tags, setTags] = useState([]) const [privateTags, setPrivateTags] = useState([]) @@ -56,28 +57,53 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { const [changing, setChanging] = useState(false) const getPrivateTags = useCallback( - async (muteListEvent: Event) => { - if (!muteListEvent.content) return [] + async ( + muteListEvent: Event + ): Promise<{ privateTags: string[][]; wasNip04: boolean }> => { + if (!muteListEvent.content) return { privateTags: [], wasNip04: false } try { + const wasNip04 = muteListEvent.content.includes('?iv=') const storedPlainText = await indexedDb.getDecryptedContent(muteListEvent.id) let plainText: string if (storedPlainText) { + console.log('[MuteList] Using cached decrypted content for event', muteListEvent.id) plainText = storedPlainText } else { - plainText = await nip04Decrypt(muteListEvent.pubkey, muteListEvent.content) + console.log('[MuteList] Decrypting content with', wasNip04 ? 'NIP-04' : 'NIP-44', 'for event', muteListEvent.id) + plainText = wasNip04 + ? await nip04Decrypt(muteListEvent.pubkey, muteListEvent.content) + : await nip44Decrypt(muteListEvent.pubkey, muteListEvent.content) await indexedDb.putDecryptedContent(muteListEvent.id, plainText) } const privateTags = z.array(z.array(z.string())).parse(JSON.parse(plainText)) - return privateTags + console.log('[MuteList] Decrypted privateTags count:', privateTags.length, 'wasNip04:', wasNip04) + return { privateTags, wasNip04 } } catch (error) { console.error('Failed to decrypt mute list content', error) - return [] + return { privateTags: [], wasNip04: false } } }, - [nip04Decrypt] + [nip04Decrypt, nip44Decrypt] + ) + + const migrateToNip44 = useCallback( + async (muteListEvent: Event, privateTags: string[][]) => { + if (!accountPubkey) return + console.log('[MuteList] Migrating from NIP-04 to NIP-44, privateTags count:', privateTags.length) + try { + const cipherText = await nip44Encrypt(accountPubkey, JSON.stringify(privateTags)) + const newMuteListDraftEvent = createMuteListDraftEvent(muteListEvent.tags, cipherText) + const event = await publish(newMuteListDraftEvent) + console.log('[MuteList] Migration successful, new event id:', event.id) + await updateMuteListEvent(event, privateTags) + } catch (error) { + console.error('[MuteList] Failed to migrate to NIP-44', error) + } + }, + [accountPubkey, nip44Encrypt, publish, updateMuteListEvent] ) useEffect(() => { @@ -88,11 +114,16 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { return } - const privateTags = await getPrivateTags(muteListEvent).catch(() => { - return [] - }) + const { privateTags, wasNip04 } = await getPrivateTags(muteListEvent).catch(() => ({ + privateTags: [] as string[][], + wasNip04: false + })) setPrivateTags(privateTags) setTags(muteListEvent.tags) + + if (wasNip04 && privateTags.length > 0) { + migrateToNip44(muteListEvent, privateTags) + } } updateMuteTags() }, [muteListEvent]) @@ -143,8 +174,14 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { return } const newTags = (muteListEvent?.tags ?? []).concat([['p', pubkey]]) - const newMuteListEvent = await publishNewMuteListEvent(newTags, muteListEvent?.content) - const privateTags = await getPrivateTags(newMuteListEvent) + const { privateTags } = muteListEvent + ? await getPrivateTags(muteListEvent) + : { privateTags: [] } + const cipherText = + privateTags.length > 0 + ? await nip44Encrypt(accountPubkey, JSON.stringify(privateTags)) + : '' + const newMuteListEvent = await publishNewMuteListEvent(newTags, cipherText) await updateMuteListEvent(newMuteListEvent, privateTags) } catch (error) { const errors = formatError(error) @@ -163,13 +200,15 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { try { const muteListEvent = await client.fetchMuteListEvent(accountPubkey) checkMuteListEvent(muteListEvent) - const privateTags = muteListEvent ? await getPrivateTags(muteListEvent) : [] + const { privateTags } = muteListEvent + ? await getPrivateTags(muteListEvent) + : { privateTags: [] as string[][] } if (privateTags.some(([tagName, tagValue]) => tagName === 'p' && tagValue === pubkey)) { return } const newPrivateTags = privateTags.concat([['p', pubkey]]) - const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags)) + const cipherText = await nip44Encrypt(accountPubkey, JSON.stringify(newPrivateTags)) const newMuteListEvent = await publishNewMuteListEvent(muteListEvent?.tags ?? [], cipherText) await updateMuteListEvent(newMuteListEvent, newPrivateTags) } catch (error) { @@ -190,11 +229,11 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { const muteListEvent = await client.fetchMuteListEvent(accountPubkey) if (!muteListEvent) return - const privateTags = await getPrivateTags(muteListEvent) + const { privateTags } = await getPrivateTags(muteListEvent) const newPrivateTags = privateTags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey) let cipherText = muteListEvent.content if (newPrivateTags.length !== privateTags.length) { - cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags)) + cipherText = await nip44Encrypt(accountPubkey, JSON.stringify(newPrivateTags)) } const newMuteListEvent = await publishNewMuteListEvent( @@ -220,13 +259,13 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { const muteListEvent = await client.fetchMuteListEvent(accountPubkey) if (!muteListEvent) return - const privateTags = await getPrivateTags(muteListEvent) + const { privateTags } = await getPrivateTags(muteListEvent) const newPrivateTags = privateTags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey) if (newPrivateTags.length === privateTags.length) { return } - const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags)) + const cipherText = await nip44Encrypt(accountPubkey, JSON.stringify(newPrivateTags)) const newMuteListEvent = await publishNewMuteListEvent( muteListEvent.tags .filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey) @@ -257,11 +296,11 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { return } - const privateTags = await getPrivateTags(muteListEvent) + const { privateTags } = await getPrivateTags(muteListEvent) const newPrivateTags = privateTags .filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey) .concat([['p', pubkey]]) - const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags)) + const cipherText = await nip44Encrypt(accountPubkey, JSON.stringify(newPrivateTags)) const newMuteListEvent = await publishNewMuteListEvent(newTags, cipherText) await updateMuteListEvent(newMuteListEvent, newPrivateTags) } catch (error) { diff --git a/src/providers/NostrProvider/bunker.signer.ts b/src/providers/NostrProvider/bunker.signer.ts index ab29a69..570906f 100644 --- a/src/providers/NostrProvider/bunker.signer.ts +++ b/src/providers/NostrProvider/bunker.signer.ts @@ -68,6 +68,20 @@ export class BunkerSigner implements ISigner { return await this.signer.nip04Decrypt(pubkey, cipherText) } + async nip44Encrypt(pubkey: string, plainText: string) { + if (!this.signer) { + throw new Error('Not logged in') + } + return await this.signer.nip44Encrypt(pubkey, plainText) + } + + async nip44Decrypt(pubkey: string, cipherText: string) { + if (!this.signer) { + throw new Error('Not logged in') + } + return await this.signer.nip44Decrypt(pubkey, cipherText) + } + getClientSecretKey() { return bytesToHex(this.clientSecretKey) } diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 8bbad75..0defdfc 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -81,6 +81,8 @@ type TNostrContext = { signEvent: (draftEvent: TDraftEvent) => Promise nip04Encrypt: (pubkey: string, plainText: string) => Promise nip04Decrypt: (pubkey: string, cipherText: string) => Promise + nip44Encrypt: (pubkey: string, plainText: string) => Promise + nip44Decrypt: (pubkey: string, cipherText: string) => Promise startLogin: () => void checkLogin: (cb?: () => T) => Promise updateRelayListEvent: (relayListEvent: Event) => Promise @@ -730,6 +732,14 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { return signer?.nip04Decrypt(pubkey, cipherText) ?? '' } + const nip44Encrypt = async (pubkey: string, plainText: string) => { + return signer?.nip44Encrypt(pubkey, plainText) ?? '' + } + + const nip44Decrypt = async (pubkey: string, cipherText: string) => { + return signer?.nip44Decrypt(pubkey, cipherText) ?? '' + } + const checkLogin = async (cb?: () => T): Promise => { if (signer) { return cb && cb() @@ -857,6 +867,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { signHttpAuth, nip04Encrypt, nip04Decrypt, + nip44Encrypt, + nip44Decrypt, startLogin: () => setOpenLoginDialog(true), checkLogin, signEvent, diff --git a/src/providers/NostrProvider/nip-07.signer.ts b/src/providers/NostrProvider/nip-07.signer.ts index 0736bd3..5718346 100644 --- a/src/providers/NostrProvider/nip-07.signer.ts +++ b/src/providers/NostrProvider/nip-07.signer.ts @@ -57,4 +57,24 @@ export class Nip07Signer implements ISigner { } return await this.signer.nip04.decrypt(pubkey, cipherText) } + + async nip44Encrypt(pubkey: string, plainText: string) { + if (!this.signer) { + throw new Error('Should call init() first') + } + if (!this.signer.nip44?.encrypt) { + throw new Error('The extension you are using does not support nip44 encryption') + } + return await this.signer.nip44.encrypt(pubkey, plainText) + } + + async nip44Decrypt(pubkey: string, cipherText: string) { + if (!this.signer) { + throw new Error('Should call init() first') + } + if (!this.signer.nip44?.decrypt) { + throw new Error('The extension you are using does not support nip44 decryption') + } + return await this.signer.nip44.decrypt(pubkey, cipherText) + } } diff --git a/src/providers/NostrProvider/nostrConnection.signer.ts b/src/providers/NostrProvider/nostrConnection.signer.ts index 3c79de6..556aa90 100644 --- a/src/providers/NostrProvider/nostrConnection.signer.ts +++ b/src/providers/NostrProvider/nostrConnection.signer.ts @@ -66,6 +66,20 @@ export class NostrConnectionSigner implements ISigner { return await this.signer.nip04Decrypt(pubkey, cipherText) } + async nip44Encrypt(pubkey: string, plainText: string) { + if (!this.signer) { + throw new Error('Not logged in') + } + return await this.signer.nip44Encrypt(pubkey, plainText) + } + + async nip44Decrypt(pubkey: string, cipherText: string) { + if (!this.signer) { + throw new Error('Not logged in') + } + return await this.signer.nip44Decrypt(pubkey, cipherText) + } + getClientSecretKey() { return bytesToHex(this.clientSecretKey) } diff --git a/src/providers/NostrProvider/npub.signer.ts b/src/providers/NostrProvider/npub.signer.ts index 68b32b3..502d651 100644 --- a/src/providers/NostrProvider/npub.signer.ts +++ b/src/providers/NostrProvider/npub.signer.ts @@ -31,4 +31,12 @@ export class NpubSigner implements ISigner { async nip04Decrypt(): Promise { throw new Error('Not logged in') } + + async nip44Encrypt(): Promise { + throw new Error('Not logged in') + } + + async nip44Decrypt(): Promise { + throw new Error('Not logged in') + } } diff --git a/src/providers/NostrProvider/nsec.signer.ts b/src/providers/NostrProvider/nsec.signer.ts index 138575a..936ac38 100644 --- a/src/providers/NostrProvider/nsec.signer.ts +++ b/src/providers/NostrProvider/nsec.signer.ts @@ -1,5 +1,6 @@ import { ISigner, TDraftEvent } from '@/types' import { finalizeEvent, getPublicKey as nGetPublicKey, nip04, nip19 } from 'nostr-tools' +import { v2 as nip44 } from 'nostr-tools/nip44' export class NsecSigner implements ISigner { private privkey: Uint8Array | null = null @@ -50,4 +51,20 @@ export class NsecSigner implements ISigner { } return nip04.decrypt(this.privkey, pubkey, cipherText) } + + async nip44Encrypt(pubkey: string, plainText: string) { + if (!this.privkey) { + throw new Error('Not logged in') + } + const conversationKey = nip44.utils.getConversationKey(this.privkey, pubkey) + return nip44.encrypt(plainText, conversationKey) + } + + async nip44Decrypt(pubkey: string, cipherText: string) { + if (!this.privkey) { + throw new Error('Not logged in') + } + const conversationKey = nip44.utils.getConversationKey(this.privkey, pubkey) + return nip44.decrypt(cipherText, conversationKey) + } } diff --git a/src/providers/PinnedUsersProvider.tsx b/src/providers/PinnedUsersProvider.tsx index 42f6899..093ca93 100644 --- a/src/providers/PinnedUsersProvider.tsx +++ b/src/providers/PinnedUsersProvider.tsx @@ -42,7 +42,8 @@ export function PinnedUsersProvider({ children }: { children: React.ReactNode }) updatePinnedUsersEvent, publish, nip04Decrypt, - nip04Encrypt + nip44Encrypt, + nip44Decrypt } = useNostr() const [privateTags, setPrivateTags] = useState([]) const pinnedPubkeySet = useMemo(() => { @@ -50,6 +51,23 @@ export function PinnedUsersProvider({ children }: { children: React.ReactNode }) return new Set(getPubkeysFromPTags(pinnedUsersEvent.tags.concat(privateTags))) }, [pinnedUsersEvent, privateTags]) + const migrateToNip44 = useCallback( + async (event: Event, privateTags: string[][]) => { + if (!accountPubkey) return + console.log('[PinnedUsers] Migrating from NIP-04 to NIP-44, privateTags count:', privateTags.length) + try { + const cipherText = await nip44Encrypt(accountPubkey, JSON.stringify(privateTags)) + const draftEvent = createPinnedUsersListDraftEvent(event.tags, cipherText) + const newEvent = await publish(draftEvent) + console.log('[PinnedUsers] Migration successful, new event id:', newEvent.id) + await updatePinnedUsersEvent(newEvent, privateTags) + } catch (error) { + console.error('[PinnedUsers] Failed to migrate to NIP-44', error) + } + }, + [accountPubkey, nip44Encrypt, publish, updatePinnedUsersEvent] + ) + useEffect(() => { const updatePrivateTags = async () => { if (!pinnedUsersEvent) { @@ -57,37 +75,48 @@ export function PinnedUsersProvider({ children }: { children: React.ReactNode }) return } - const privateTags = await getPrivateTags(pinnedUsersEvent).catch(() => { - return [] - }) + const { privateTags, wasNip04 } = await getPrivateTags(pinnedUsersEvent).catch(() => ({ + privateTags: [] as string[][], + wasNip04: false + })) setPrivateTags(privateTags) + + if (wasNip04 && privateTags.length > 0) { + migrateToNip44(pinnedUsersEvent, privateTags) + } } updatePrivateTags() }, [pinnedUsersEvent]) const getPrivateTags = useCallback( - async (event: Event) => { - if (!event.content) return [] + async (event: Event): Promise<{ privateTags: string[][]; wasNip04: boolean }> => { + if (!event.content) return { privateTags: [], wasNip04: false } try { + const wasNip04 = event.content.includes('?iv=') const storedPlainText = await indexedDb.getDecryptedContent(event.id) let plainText: string if (storedPlainText) { + console.log('[PinnedUsers] Using cached decrypted content for event', event.id) plainText = storedPlainText } else { - plainText = await nip04Decrypt(event.pubkey, event.content) + console.log('[PinnedUsers] Decrypting content with', wasNip04 ? 'NIP-04' : 'NIP-44', 'for event', event.id) + plainText = wasNip04 + ? await nip04Decrypt(event.pubkey, event.content) + : await nip44Decrypt(event.pubkey, event.content) await indexedDb.putDecryptedContent(event.id, plainText) } const privateTags = z.array(z.array(z.string())).parse(JSON.parse(plainText)) - return privateTags + console.log('[PinnedUsers] Decrypted privateTags count:', privateTags.length, 'wasNip04:', wasNip04) + return { privateTags, wasNip04 } } catch (error) { console.error('Failed to decrypt pinned users content', error) - return [] + return { privateTags: [], wasNip04: false } } }, - [nip04Decrypt] + [nip04Decrypt, nip44Decrypt] ) const isPinned = useCallback( @@ -129,7 +158,7 @@ export function PinnedUsersProvider({ children }: { children: React.ReactNode }) ) let newContent = pinnedUsersEvent.content if (newPrivateTags.length !== privateTags.length) { - newContent = await nip04Encrypt(pinnedUsersEvent.pubkey, JSON.stringify(newPrivateTags)) + newContent = await nip44Encrypt(pinnedUsersEvent.pubkey, JSON.stringify(newPrivateTags)) } const draftEvent = createPinnedUsersListDraftEvent(newTags, newContent) const newEvent = await publish(draftEvent) @@ -148,7 +177,7 @@ export function PinnedUsersProvider({ children }: { children: React.ReactNode }) publish, updatePinnedUsersEvent, privateTags, - nip04Encrypt + nip44Encrypt ] ) diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 1831554..225b422 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -91,6 +91,10 @@ export type TNip07 = { encrypt?: (pubkey: string, plainText: string) => Promise decrypt?: (pubkey: string, cipherText: string) => Promise } + nip44?: { + encrypt?: (pubkey: string, plainText: string) => Promise + decrypt?: (pubkey: string, cipherText: string) => Promise + } } export interface ISigner { @@ -98,6 +102,8 @@ export interface ISigner { signEvent: (draftEvent: TDraftEvent) => Promise nip04Encrypt: (pubkey: string, plainText: string) => Promise nip04Decrypt: (pubkey: string, cipherText: string) => Promise + nip44Encrypt: (pubkey: string, plainText: string) => Promise + nip44Decrypt: (pubkey: string, cipherText: string) => Promise } export type TSignerType = 'nsec' | 'nip-07' | 'bunker' | 'browser-nsec' | 'ncryptsec' | 'npub'