From 685d357573185357aef85fdb241ec57f64d2cfa4 Mon Sep 17 00:00:00 2001 From: Dorian Date: Tue, 3 Mar 2026 21:02:53 +0000 Subject: [PATCH] feat(app): add Nostr identity login via NIP-07 browser extension Create useNostrIdentity.ts composable with NIP-07 window.nostr API integration (getPublicKey, signEvent, extension detection). Add npub bech32 encoding to bech32.ts. Create NostrLogin.vue settings component with login/logout, npub display with copy, and extension install guidance. Co-Authored-By: Claude Opus 4.6 --- .../src/components/settings/NostrLogin.vue | 120 ++++++++++++++++++ .../app/src/composables/useNostrIdentity.ts | 119 +++++++++++++++++ packages/app/src/utils/bech32.ts | 28 ++++ 3 files changed, 267 insertions(+) create mode 100644 packages/app/src/components/settings/NostrLogin.vue create mode 100644 packages/app/src/composables/useNostrIdentity.ts diff --git a/packages/app/src/components/settings/NostrLogin.vue b/packages/app/src/components/settings/NostrLogin.vue new file mode 100644 index 00000000..656f0d21 --- /dev/null +++ b/packages/app/src/components/settings/NostrLogin.vue @@ -0,0 +1,120 @@ + + + diff --git a/packages/app/src/composables/useNostrIdentity.ts b/packages/app/src/composables/useNostrIdentity.ts new file mode 100644 index 00000000..1bbc7efd --- /dev/null +++ b/packages/app/src/composables/useNostrIdentity.ts @@ -0,0 +1,119 @@ +import { ref, computed, onMounted } from 'vue' +import { encodeNpub } from '@/utils/bech32' + +/** + * NIP-07 browser extension interface (nos2x, Alby, etc.) + */ +interface NostrExtension { + getPublicKey(): Promise + signEvent(event: UnsignedNostrEvent): Promise + getRelays?(): Promise> + nip04?: { + encrypt(pubkey: string, plaintext: string): Promise + decrypt(pubkey: string, ciphertext: string): Promise + } +} + +export interface UnsignedNostrEvent { + kind: number + created_at: number + tags: string[][] + content: string +} + +export interface SignedNostrEvent extends UnsignedNostrEvent { + id: string + pubkey: string + sig: string +} + +declare global { + interface Window { + nostr?: NostrExtension + } +} + +const pubkey = ref(null) +const isAvailable = ref(false) +const isLoading = ref(false) +const error = ref(null) + +export function useNostrIdentity() { + const npub = computed(() => { + if (!pubkey.value) return null + try { + return encodeNpub(pubkey.value) + } catch { + return null + } + }) + + const isLoggedIn = computed(() => !!pubkey.value) + + const truncatedNpub = computed(() => { + if (!npub.value) return null + return npub.value.slice(0, 12) + '...' + npub.value.slice(-8) + }) + + function checkAvailability() { + isAvailable.value = typeof window !== 'undefined' && !!window.nostr + } + + async function login(): Promise { + error.value = null + if (!window.nostr) { + error.value = 'No Nostr extension detected. Install nos2x, Alby, or another NIP-07 extension.' + return + } + + isLoading.value = true + try { + const pk = await window.nostr.getPublicKey() + pubkey.value = pk + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to get public key' + } finally { + isLoading.value = false + } + } + + async function signEvent(event: UnsignedNostrEvent): Promise { + error.value = null + if (!window.nostr) { + error.value = 'No Nostr extension detected' + return null + } + + try { + return await window.nostr.signEvent(event) + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to sign event' + return null + } + } + + function logout() { + pubkey.value = null + error.value = null + } + + onMounted(() => { + checkAvailability() + // Some extensions load async — recheck after a short delay + setTimeout(checkAvailability, 500) + }) + + return { + pubkey, + npub, + isAvailable, + isLoggedIn, + isLoading, + error, + truncatedNpub, + login, + logout, + signEvent, + checkAvailability, + } +} diff --git a/packages/app/src/utils/bech32.ts b/packages/app/src/utils/bech32.ts index 1c54c902..40965549 100644 --- a/packages/app/src/utils/bech32.ts +++ b/packages/app/src/utils/bech32.ts @@ -81,6 +81,34 @@ export function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('') } +export function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16) + } + return bytes +} + +function createChecksum(hrp: string, data: number[]): number[] { + const values = [...hrpExpand(hrp), ...data, 0, 0, 0, 0, 0, 0] + const mod = polymod(values) ^ 1 + const ret: number[] = [] + for (let i = 0; i < 6; i++) ret.push((mod >> (5 * (5 - i))) & 31) + return ret +} + +export function bech32Encode(hrp: string, data: Uint8Array): string { + const fiveBit = convertBits(Array.from(data), 8, 5, true) + if (!fiveBit) throw new Error('Failed to convert bits') + const checksum = createChecksum(hrp, fiveBit) + return hrp + '1' + [...fiveBit, ...checksum].map(d => CHARSET[d]).join('') +} + +/** Encode a hex public key as npub1... */ +export function encodeNpub(hexPubkey: string): string { + return bech32Encode('npub', hexToBytes(hexPubkey)) +} + export interface NIP19Decoded { type: 'npub' | 'note' | 'nevent' | 'nprofile' | 'unknown' hex: string