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) <noreply@anthropic.com>
This commit is contained in:
codytseng 2026-04-04 23:52:49 +08:00
parent 4fb40e81b3
commit 2efc884e01
9 changed files with 191 additions and 32 deletions

View file

@ -41,7 +41,8 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
publish, publish,
updateMuteListEvent, updateMuteListEvent,
nip04Decrypt, nip04Decrypt,
nip04Encrypt nip44Encrypt,
nip44Decrypt
} = useNostr() } = useNostr()
const [tags, setTags] = useState<string[][]>([]) const [tags, setTags] = useState<string[][]>([])
const [privateTags, setPrivateTags] = useState<string[][]>([]) const [privateTags, setPrivateTags] = useState<string[][]>([])
@ -56,28 +57,53 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
const [changing, setChanging] = useState(false) const [changing, setChanging] = useState(false)
const getPrivateTags = useCallback( const getPrivateTags = useCallback(
async (muteListEvent: Event) => { async (
if (!muteListEvent.content) return [] muteListEvent: Event
): Promise<{ privateTags: string[][]; wasNip04: boolean }> => {
if (!muteListEvent.content) return { privateTags: [], wasNip04: false }
try { try {
const wasNip04 = muteListEvent.content.includes('?iv=')
const storedPlainText = await indexedDb.getDecryptedContent(muteListEvent.id) const storedPlainText = await indexedDb.getDecryptedContent(muteListEvent.id)
let plainText: string let plainText: string
if (storedPlainText) { if (storedPlainText) {
console.log('[MuteList] Using cached decrypted content for event', muteListEvent.id)
plainText = storedPlainText plainText = storedPlainText
} else { } 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) await indexedDb.putDecryptedContent(muteListEvent.id, plainText)
} }
const privateTags = z.array(z.array(z.string())).parse(JSON.parse(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) { } catch (error) {
console.error('Failed to decrypt mute list content', 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(() => { useEffect(() => {
@ -88,11 +114,16 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
return return
} }
const privateTags = await getPrivateTags(muteListEvent).catch(() => { const { privateTags, wasNip04 } = await getPrivateTags(muteListEvent).catch(() => ({
return [] privateTags: [] as string[][],
}) wasNip04: false
}))
setPrivateTags(privateTags) setPrivateTags(privateTags)
setTags(muteListEvent.tags) setTags(muteListEvent.tags)
if (wasNip04 && privateTags.length > 0) {
migrateToNip44(muteListEvent, privateTags)
}
} }
updateMuteTags() updateMuteTags()
}, [muteListEvent]) }, [muteListEvent])
@ -143,8 +174,14 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
return return
} }
const newTags = (muteListEvent?.tags ?? []).concat([['p', pubkey]]) const newTags = (muteListEvent?.tags ?? []).concat([['p', pubkey]])
const newMuteListEvent = await publishNewMuteListEvent(newTags, muteListEvent?.content) const { privateTags } = muteListEvent
const privateTags = await getPrivateTags(newMuteListEvent) ? 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) await updateMuteListEvent(newMuteListEvent, privateTags)
} catch (error) { } catch (error) {
const errors = formatError(error) const errors = formatError(error)
@ -163,13 +200,15 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
try { try {
const muteListEvent = await client.fetchMuteListEvent(accountPubkey) const muteListEvent = await client.fetchMuteListEvent(accountPubkey)
checkMuteListEvent(muteListEvent) 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)) { if (privateTags.some(([tagName, tagValue]) => tagName === 'p' && tagValue === pubkey)) {
return return
} }
const newPrivateTags = privateTags.concat([['p', pubkey]]) 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) const newMuteListEvent = await publishNewMuteListEvent(muteListEvent?.tags ?? [], cipherText)
await updateMuteListEvent(newMuteListEvent, newPrivateTags) await updateMuteListEvent(newMuteListEvent, newPrivateTags)
} catch (error) { } catch (error) {
@ -190,11 +229,11 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
const muteListEvent = await client.fetchMuteListEvent(accountPubkey) const muteListEvent = await client.fetchMuteListEvent(accountPubkey)
if (!muteListEvent) return if (!muteListEvent) return
const privateTags = await getPrivateTags(muteListEvent) const { privateTags } = await getPrivateTags(muteListEvent)
const newPrivateTags = privateTags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey) const newPrivateTags = privateTags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey)
let cipherText = muteListEvent.content let cipherText = muteListEvent.content
if (newPrivateTags.length !== privateTags.length) { if (newPrivateTags.length !== privateTags.length) {
cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags)) cipherText = await nip44Encrypt(accountPubkey, JSON.stringify(newPrivateTags))
} }
const newMuteListEvent = await publishNewMuteListEvent( const newMuteListEvent = await publishNewMuteListEvent(
@ -220,13 +259,13 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
const muteListEvent = await client.fetchMuteListEvent(accountPubkey) const muteListEvent = await client.fetchMuteListEvent(accountPubkey)
if (!muteListEvent) return if (!muteListEvent) return
const privateTags = await getPrivateTags(muteListEvent) const { privateTags } = await getPrivateTags(muteListEvent)
const newPrivateTags = privateTags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey) const newPrivateTags = privateTags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey)
if (newPrivateTags.length === privateTags.length) { if (newPrivateTags.length === privateTags.length) {
return return
} }
const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags)) const cipherText = await nip44Encrypt(accountPubkey, JSON.stringify(newPrivateTags))
const newMuteListEvent = await publishNewMuteListEvent( const newMuteListEvent = await publishNewMuteListEvent(
muteListEvent.tags muteListEvent.tags
.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey) .filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey)
@ -257,11 +296,11 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
return return
} }
const privateTags = await getPrivateTags(muteListEvent) const { privateTags } = await getPrivateTags(muteListEvent)
const newPrivateTags = privateTags const newPrivateTags = privateTags
.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey) .filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey)
.concat([['p', 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) const newMuteListEvent = await publishNewMuteListEvent(newTags, cipherText)
await updateMuteListEvent(newMuteListEvent, newPrivateTags) await updateMuteListEvent(newMuteListEvent, newPrivateTags)
} catch (error) { } catch (error) {

View file

@ -68,6 +68,20 @@ export class BunkerSigner implements ISigner {
return await this.signer.nip04Decrypt(pubkey, cipherText) 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() { getClientSecretKey() {
return bytesToHex(this.clientSecretKey) return bytesToHex(this.clientSecretKey)
} }

View file

@ -81,6 +81,8 @@ type TNostrContext = {
signEvent: (draftEvent: TDraftEvent) => Promise<VerifiedEvent> signEvent: (draftEvent: TDraftEvent) => Promise<VerifiedEvent>
nip04Encrypt: (pubkey: string, plainText: string) => Promise<string> nip04Encrypt: (pubkey: string, plainText: string) => Promise<string>
nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string> nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
nip44Encrypt: (pubkey: string, plainText: string) => Promise<string>
nip44Decrypt: (pubkey: string, cipherText: string) => Promise<string>
startLogin: () => void startLogin: () => void
checkLogin: <T>(cb?: () => T) => Promise<T | void> checkLogin: <T>(cb?: () => T) => Promise<T | void>
updateRelayListEvent: (relayListEvent: Event) => Promise<void> updateRelayListEvent: (relayListEvent: Event) => Promise<void>
@ -730,6 +732,14 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return signer?.nip04Decrypt(pubkey, cipherText) ?? '' 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 <T,>(cb?: () => T): Promise<T | void> => { const checkLogin = async <T,>(cb?: () => T): Promise<T | void> => {
if (signer) { if (signer) {
return cb && cb() return cb && cb()
@ -857,6 +867,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
signHttpAuth, signHttpAuth,
nip04Encrypt, nip04Encrypt,
nip04Decrypt, nip04Decrypt,
nip44Encrypt,
nip44Decrypt,
startLogin: () => setOpenLoginDialog(true), startLogin: () => setOpenLoginDialog(true),
checkLogin, checkLogin,
signEvent, signEvent,

View file

@ -57,4 +57,24 @@ export class Nip07Signer implements ISigner {
} }
return await this.signer.nip04.decrypt(pubkey, cipherText) 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)
}
} }

View file

@ -66,6 +66,20 @@ export class NostrConnectionSigner implements ISigner {
return await this.signer.nip04Decrypt(pubkey, cipherText) 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() { getClientSecretKey() {
return bytesToHex(this.clientSecretKey) return bytesToHex(this.clientSecretKey)
} }

View file

@ -31,4 +31,12 @@ export class NpubSigner implements ISigner {
async nip04Decrypt(): Promise<any> { async nip04Decrypt(): Promise<any> {
throw new Error('Not logged in') throw new Error('Not logged in')
} }
async nip44Encrypt(): Promise<any> {
throw new Error('Not logged in')
}
async nip44Decrypt(): Promise<any> {
throw new Error('Not logged in')
}
} }

View file

@ -1,5 +1,6 @@
import { ISigner, TDraftEvent } from '@/types' import { ISigner, TDraftEvent } from '@/types'
import { finalizeEvent, getPublicKey as nGetPublicKey, nip04, nip19 } from 'nostr-tools' import { finalizeEvent, getPublicKey as nGetPublicKey, nip04, nip19 } from 'nostr-tools'
import { v2 as nip44 } from 'nostr-tools/nip44'
export class NsecSigner implements ISigner { export class NsecSigner implements ISigner {
private privkey: Uint8Array | null = null private privkey: Uint8Array | null = null
@ -50,4 +51,20 @@ export class NsecSigner implements ISigner {
} }
return nip04.decrypt(this.privkey, pubkey, cipherText) 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)
}
} }

View file

@ -42,7 +42,8 @@ export function PinnedUsersProvider({ children }: { children: React.ReactNode })
updatePinnedUsersEvent, updatePinnedUsersEvent,
publish, publish,
nip04Decrypt, nip04Decrypt,
nip04Encrypt nip44Encrypt,
nip44Decrypt
} = useNostr() } = useNostr()
const [privateTags, setPrivateTags] = useState<string[][]>([]) const [privateTags, setPrivateTags] = useState<string[][]>([])
const pinnedPubkeySet = useMemo(() => { const pinnedPubkeySet = useMemo(() => {
@ -50,6 +51,23 @@ export function PinnedUsersProvider({ children }: { children: React.ReactNode })
return new Set(getPubkeysFromPTags(pinnedUsersEvent.tags.concat(privateTags))) return new Set(getPubkeysFromPTags(pinnedUsersEvent.tags.concat(privateTags)))
}, [pinnedUsersEvent, 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(() => { useEffect(() => {
const updatePrivateTags = async () => { const updatePrivateTags = async () => {
if (!pinnedUsersEvent) { if (!pinnedUsersEvent) {
@ -57,37 +75,48 @@ export function PinnedUsersProvider({ children }: { children: React.ReactNode })
return return
} }
const privateTags = await getPrivateTags(pinnedUsersEvent).catch(() => { const { privateTags, wasNip04 } = await getPrivateTags(pinnedUsersEvent).catch(() => ({
return [] privateTags: [] as string[][],
}) wasNip04: false
}))
setPrivateTags(privateTags) setPrivateTags(privateTags)
if (wasNip04 && privateTags.length > 0) {
migrateToNip44(pinnedUsersEvent, privateTags)
}
} }
updatePrivateTags() updatePrivateTags()
}, [pinnedUsersEvent]) }, [pinnedUsersEvent])
const getPrivateTags = useCallback( const getPrivateTags = useCallback(
async (event: Event) => { async (event: Event): Promise<{ privateTags: string[][]; wasNip04: boolean }> => {
if (!event.content) return [] if (!event.content) return { privateTags: [], wasNip04: false }
try { try {
const wasNip04 = event.content.includes('?iv=')
const storedPlainText = await indexedDb.getDecryptedContent(event.id) const storedPlainText = await indexedDb.getDecryptedContent(event.id)
let plainText: string let plainText: string
if (storedPlainText) { if (storedPlainText) {
console.log('[PinnedUsers] Using cached decrypted content for event', event.id)
plainText = storedPlainText plainText = storedPlainText
} else { } 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) await indexedDb.putDecryptedContent(event.id, plainText)
} }
const privateTags = z.array(z.array(z.string())).parse(JSON.parse(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) { } catch (error) {
console.error('Failed to decrypt pinned users content', error) console.error('Failed to decrypt pinned users content', error)
return [] return { privateTags: [], wasNip04: false }
} }
}, },
[nip04Decrypt] [nip04Decrypt, nip44Decrypt]
) )
const isPinned = useCallback( const isPinned = useCallback(
@ -129,7 +158,7 @@ export function PinnedUsersProvider({ children }: { children: React.ReactNode })
) )
let newContent = pinnedUsersEvent.content let newContent = pinnedUsersEvent.content
if (newPrivateTags.length !== privateTags.length) { 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 draftEvent = createPinnedUsersListDraftEvent(newTags, newContent)
const newEvent = await publish(draftEvent) const newEvent = await publish(draftEvent)
@ -148,7 +177,7 @@ export function PinnedUsersProvider({ children }: { children: React.ReactNode })
publish, publish,
updatePinnedUsersEvent, updatePinnedUsersEvent,
privateTags, privateTags,
nip04Encrypt nip44Encrypt
] ]
) )

View file

@ -91,6 +91,10 @@ export type TNip07 = {
encrypt?: (pubkey: string, plainText: string) => Promise<string> encrypt?: (pubkey: string, plainText: string) => Promise<string>
decrypt?: (pubkey: string, cipherText: string) => Promise<string> decrypt?: (pubkey: string, cipherText: string) => Promise<string>
} }
nip44?: {
encrypt?: (pubkey: string, plainText: string) => Promise<string>
decrypt?: (pubkey: string, cipherText: string) => Promise<string>
}
} }
export interface ISigner { export interface ISigner {
@ -98,6 +102,8 @@ export interface ISigner {
signEvent: (draftEvent: TDraftEvent) => Promise<VerifiedEvent> signEvent: (draftEvent: TDraftEvent) => Promise<VerifiedEvent>
nip04Encrypt: (pubkey: string, plainText: string) => Promise<string> nip04Encrypt: (pubkey: string, plainText: string) => Promise<string>
nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string> nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
nip44Encrypt: (pubkey: string, plainText: string) => Promise<string>
nip44Decrypt: (pubkey: string, cipherText: string) => Promise<string>
} }
export type TSignerType = 'nsec' | 'nip-07' | 'bunker' | 'browser-nsec' | 'ncryptsec' | 'npub' export type TSignerType = 'nsec' | 'nip-07' | 'bunker' | 'browser-nsec' | 'ncryptsec' | 'npub'