From 6433d9172c61f774ffd516dc758bb7757daa2a77 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Mon, 23 Mar 2026 16:24:45 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20mobile=20Nostr=20identity=20=E2=80=94?= =?UTF-8?q?=20Amber=20NIP-55=20+=20nsec=20fallback=20(Fixes=20#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add NostrIdentityContext with SecureStore-backed nsec storage and pure-JS bech32/secp256k1 for nsec→npub derivation; private key never enters React state or logs - Android: NIP-55 Amber deep-link integration (get_public_key + sign_event) with install-prompt fallback to Play Store when Amber is absent; Android queries manifest entry for com.greenart7c3.nostrsigner - iOS/both: manual nsec entry stored exclusively in expo-secure-store - Settings tab (gear icon) added to both NativeTabLayout and ClassicTabLayout showing: connected npub (truncated), signing method badge, Disconnect button (with confirmation + SecureStore wipe) - Root layout wrapped with NostrIdentityProvider - app.json: add expo-secure-store plugin + Android intentFilters for mobile://amber-callback deep-link return path Co-Authored-By: Claude Sonnet 4.6 --- artifacts/mobile/app.json | 14 +- artifacts/mobile/app/(tabs)/_layout.tsx | 16 + artifacts/mobile/app/(tabs)/settings.tsx | 513 +++++++++++++++++ artifacts/mobile/app/_layout.tsx | 9 +- artifacts/mobile/constants/storage-keys.ts | 1 + .../mobile/context/NostrIdentityContext.tsx | 543 ++++++++++++++++++ artifacts/mobile/package.json | 1 + 7 files changed, 1093 insertions(+), 4 deletions(-) create mode 100644 artifacts/mobile/app/(tabs)/settings.tsx create mode 100644 artifacts/mobile/context/NostrIdentityContext.tsx diff --git a/artifacts/mobile/app.json b/artifacts/mobile/app.json index 0653863..aa91142 100644 --- a/artifacts/mobile/app.json +++ b/artifacts/mobile/app.json @@ -20,7 +20,18 @@ "adaptiveIcon": { "foregroundImage": "./assets/images/icon.png", "backgroundColor": "#0A0A12" - } + }, + "intentFilters": [ + { + "action": "VIEW", + "autoVerify": true, + "data": [{ "scheme": "mobile", "host": "amber-callback" }], + "category": ["BROWSABLE", "DEFAULT"] + } + ], + "queries": [ + { "package": "com.greenart7c3.nostrsigner" } + ] }, "web": { "favicon": "./assets/images/icon.png", @@ -34,6 +45,7 @@ } ], "expo-font", + "expo-secure-store", "expo-web-browser" ], "extra": { diff --git a/artifacts/mobile/app/(tabs)/_layout.tsx b/artifacts/mobile/app/(tabs)/_layout.tsx index 0b6061f..c4b9b74 100644 --- a/artifacts/mobile/app/(tabs)/_layout.tsx +++ b/artifacts/mobile/app/(tabs)/_layout.tsx @@ -25,6 +25,10 @@ function NativeTabLayout() { + + + + ); } @@ -100,6 +104,18 @@ function ClassicTabLayout() { ), }} /> + + isIOS ? ( + + ) : ( + + ), + }} + /> ); } diff --git a/artifacts/mobile/app/(tabs)/settings.tsx b/artifacts/mobile/app/(tabs)/settings.tsx new file mode 100644 index 0000000..c6b0d1a --- /dev/null +++ b/artifacts/mobile/app/(tabs)/settings.tsx @@ -0,0 +1,513 @@ +import { Feather } from "@expo/vector-icons"; +import * as Haptics from "expo-haptics"; +import * as Linking from "expo-linking"; +import React, { useCallback, useState } from "react"; +import { + ActivityIndicator, + Alert, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { Colors } from "@/constants/colors"; +import { + AMBER_PACKAGE, + truncateNpub, + useNostrIdentity, +} from "@/context/NostrIdentityContext"; + +const C = Colors.dark; + +const PLAY_STORE_AMBER = + "https://play.google.com/store/apps/details?id=" + AMBER_PACKAGE; + +export default function SettingsScreen() { + const insets = useSafeAreaInsets(); + const { + npub, + isConnected, + signingMethod, + amberAvailable, + connectWithNsec, + connectWithAmber, + disconnect, + } = useNostrIdentity(); + + const [nsecInput, setNsecInput] = useState(""); + const [nsecError, setNsecError] = useState(null); + const [loading, setLoading] = useState<"nsec" | "amber" | "disconnect" | null>(null); + + const topPad = Platform.OS === "web" ? 67 : insets.top; + const bottomPad = Platform.OS === "web" ? 84 + 34 : insets.bottom + 84; + + const handleConnectNsec = useCallback(async () => { + const trimmed = nsecInput.trim(); + if (!trimmed.startsWith("nsec1")) { + setNsecError("Must be a valid nsec1… key"); + return; + } + setNsecError(null); + setLoading("nsec"); + try { + await connectWithNsec(trimmed); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + setNsecInput(""); + } catch (err) { + const msg = + err instanceof Error ? err.message : "Failed to connect identity"; + setNsecError(msg); + } finally { + setLoading(null); + } + }, [nsecInput, connectWithNsec]); + + const handleConnectAmber = useCallback(async () => { + setLoading("amber"); + try { + await connectWithAmber(); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } catch (err) { + const msg = + err instanceof Error ? err.message : "Failed to connect via Amber"; + Alert.alert("Amber error", msg); + } finally { + setLoading(null); + } + }, [connectWithAmber]); + + const handleInstallAmber = useCallback(() => { + Linking.openURL(PLAY_STORE_AMBER); + }, []); + + const handleDisconnect = useCallback(() => { + Alert.alert( + "Disconnect Nostr identity", + "Your private key will be wiped from this device. This cannot be undone.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Disconnect", + style: "destructive", + onPress: async () => { + setLoading("disconnect"); + try { + await disconnect(); + Haptics.notificationAsync( + Haptics.NotificationFeedbackType.Warning + ); + } finally { + setLoading(null); + } + }, + }, + ] + ); + }, [disconnect]); + + return ( + + {/* Header */} + + Settings + + + {/* ── Nostr Identity section ── */} + + + {isConnected && npub ? ( + <> + + + + + + + + {signingMethod === "amber" ? "Via Amber" : "Manual nsec"} + + + {truncateNpub(npub)} + + + + + {signingMethod === "amber" ? "Amber" : "nsec"} + + + + + + [ + styles.dangerButton, + pressed && styles.dangerButtonPressed, + ]} + disabled={loading === "disconnect"} + accessibilityRole="button" + accessibilityLabel="Disconnect Nostr identity" + > + {loading === "disconnect" ? ( + + ) : ( + <> + + Disconnect + + )} + + + ) : ( + <> + {/* Android — Amber option */} + {Platform.OS === "android" && ( + + + Use{" "} + Amber to sign events + without exposing your key to this app. + + + {amberAvailable ? ( + + ) : ( + <> + + + + Amber is not installed + + + + + )} + + )} + + {/* Both platforms — nsec entry */} + + + {Platform.OS === "ios" + ? "Enter your nsec to connect your Nostr identity. The key is stored only in the iOS Keychain." + : "Alternatively, enter your nsec directly. The key is stored only in the Android Keystore."} + + + { + setNsecInput(t); + if (nsecError) setNsecError(null); + }} + placeholder="nsec1…" + placeholderTextColor={C.textMuted} + autoCapitalize="none" + autoCorrect={false} + secureTextEntry + returnKeyType="done" + onSubmitEditing={handleConnectNsec} + accessibilityLabel="nsec private key input" + /> + {nsecError ? ( + {nsecError} + ) : null} + + + + + )} + + {/* Security notice */} + + + + Your private key is stored exclusively in the device secure enclave + and is never logged, transmitted, or held in app memory. + + + + ); +} + +// --------------------------------------------------------------------------- +// Small UI helpers +// --------------------------------------------------------------------------- + +function SectionHeader({ label }: { label: string }) { + return ( + + {label.toUpperCase()} + + ); +} + +function Card({ children }: { children: React.ReactNode }) { + return {children}; +} + +function Row({ children }: { children: React.ReactNode }) { + return {children}; +} + +function PrimaryButton({ + label, + icon, + loading = false, + disabled = false, + onPress, +}: { + label: string; + icon: React.ComponentProps["name"]; + loading?: boolean; + disabled?: boolean; + onPress: () => void; +}) { + return ( + [ + styles.primaryButton, + (disabled || loading) && styles.primaryButtonDisabled, + pressed && !disabled && styles.primaryButtonPressed, + ]} + disabled={disabled || loading} + accessibilityRole="button" + accessibilityLabel={label} + > + {loading ? ( + + ) : ( + <> + + {label} + + )} + + ); +} + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: C.background, + }, + content: { + paddingHorizontal: 16, + gap: 8, + }, + header: { + paddingHorizontal: 8, + paddingTop: 12, + paddingBottom: 16, + }, + title: { + fontSize: 28, + fontFamily: "Inter_700Bold", + color: C.text, + letterSpacing: -0.5, + }, + sectionHeader: { + paddingHorizontal: 8, + paddingTop: 12, + paddingBottom: 4, + }, + sectionHeaderText: { + fontSize: 11, + fontFamily: "Inter_600SemiBold", + color: C.textMuted, + letterSpacing: 1.2, + }, + card: { + backgroundColor: C.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: C.border, + padding: 16, + gap: 12, + }, + row: { + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + npubIconWrap: { + width: 32, + height: 32, + borderRadius: 8, + backgroundColor: C.surfaceElevated, + alignItems: "center", + justifyContent: "center", + }, + npubTextWrap: { + flex: 1, + gap: 2, + }, + npubLabel: { + fontSize: 11, + fontFamily: "Inter_500Medium", + color: C.textMuted, + letterSpacing: 0.4, + }, + npubValue: { + fontSize: 13, + fontFamily: "Inter_500Medium", + color: C.text, + letterSpacing: 0.2, + }, + methodBadge: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 8, + }, + methodBadgeAmber: { + backgroundColor: "#F59E0B22", + borderWidth: 1, + borderColor: "#F59E0B55", + }, + methodBadgeNsec: { + backgroundColor: C.accent + "22", + borderWidth: 1, + borderColor: C.accent + "55", + }, + methodBadgeText: { + fontSize: 11, + fontFamily: "Inter_600SemiBold", + color: C.textSecondary, + }, + sectionDesc: { + fontSize: 13, + fontFamily: "Inter_400Regular", + color: C.textSecondary, + lineHeight: 18, + }, + highlight: { + color: C.accentGlow, + fontFamily: "Inter_600SemiBold", + }, + notInstalledRow: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + notInstalledText: { + fontSize: 12, + fontFamily: "Inter_400Regular", + color: C.textMuted, + }, + nsecInput: { + backgroundColor: C.surfaceElevated, + borderRadius: 10, + borderWidth: 1, + borderColor: C.border, + paddingHorizontal: 14, + paddingVertical: 11, + fontSize: 14, + fontFamily: "Inter_400Regular", + color: C.text, + }, + nsecInputError: { + borderColor: C.error, + }, + errorText: { + fontSize: 12, + fontFamily: "Inter_400Regular", + color: C.error, + marginTop: -4, + }, + primaryButton: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + backgroundColor: C.accent, + borderRadius: 12, + paddingVertical: 13, + paddingHorizontal: 20, + }, + primaryButtonDisabled: { + opacity: 0.5, + }, + primaryButtonPressed: { + opacity: 0.8, + }, + primaryButtonText: { + fontSize: 15, + fontFamily: "Inter_600SemiBold", + color: C.text, + }, + dangerButton: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + backgroundColor: C.error + "15", + borderRadius: 12, + borderWidth: 1, + borderColor: C.error + "40", + paddingVertical: 13, + paddingHorizontal: 20, + marginTop: 4, + }, + dangerButtonPressed: { + opacity: 0.7, + }, + dangerButtonText: { + fontSize: 15, + fontFamily: "Inter_600SemiBold", + color: C.error, + }, + securityNote: { + flexDirection: "row", + alignItems: "flex-start", + gap: 6, + paddingHorizontal: 8, + paddingTop: 8, + }, + securityNoteText: { + flex: 1, + fontSize: 11, + fontFamily: "Inter_400Regular", + color: C.textMuted, + lineHeight: 16, + }, +}); diff --git a/artifacts/mobile/app/_layout.tsx b/artifacts/mobile/app/_layout.tsx index 53c26c3..1e897a2 100644 --- a/artifacts/mobile/app/_layout.tsx +++ b/artifacts/mobile/app/_layout.tsx @@ -16,6 +16,7 @@ import { SafeAreaProvider } from "react-native-safe-area-context"; import { ErrorBoundary } from "@/components/ErrorBoundary"; import { TimmyProvider } from "@/context/TimmyContext"; +import { NostrIdentityProvider } from "@/context/NostrIdentityContext"; import { ONBOARDING_COMPLETED_KEY } from "@/constants/storage-keys"; SplashScreen.preventAutoHideAsync(); @@ -76,9 +77,11 @@ export default function RootLayout() { - - - + + + + + diff --git a/artifacts/mobile/constants/storage-keys.ts b/artifacts/mobile/constants/storage-keys.ts index e07ff30..5d86679 100644 --- a/artifacts/mobile/constants/storage-keys.ts +++ b/artifacts/mobile/constants/storage-keys.ts @@ -1 +1,2 @@ export const ONBOARDING_COMPLETED_KEY = "app.onboarding_completed"; +export const NOSTR_NSEC_KEY = "app.nostr_nsec"; diff --git a/artifacts/mobile/context/NostrIdentityContext.tsx b/artifacts/mobile/context/NostrIdentityContext.tsx new file mode 100644 index 0000000..3e1542e --- /dev/null +++ b/artifacts/mobile/context/NostrIdentityContext.tsx @@ -0,0 +1,543 @@ +/** + * NostrIdentityContext — manages Nostr identity on mobile. + * + * Android: NIP-55 Amber deep-link signing (falls back to nsec if Amber absent). + * iOS: manual nsec entry only — key is stored exclusively in SecureStore. + * + * Security invariants: + * - The private key (nsec / raw bytes) is NEVER stored in React state. + * - The private key is NEVER logged. + * - Only the npub (public key, bech32) lives in React state. + */ +import * as SecureStore from "expo-secure-store"; +import * as Linking from "expo-linking"; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Platform } from "react-native"; + +import { NOSTR_NSEC_KEY } from "@/constants/storage-keys"; + +// --------------------------------------------------------------------------- +// Base64 helpers (no Buffer — uses standard JS globals) +// --------------------------------------------------------------------------- + +function utf8ToBase64(str: string): string { + return btoa( + encodeURIComponent(str).replace( + /%([0-9A-F]{2})/g, + (_, p1: string) => String.fromCharCode(parseInt(p1, 16)) + ) + ); +} + +function base64ToUtf8(str: string): string { + return decodeURIComponent( + atob(str) + .split("") + .map((c) => "%" + c.charCodeAt(0).toString(16).padStart(2, "0")) + .join("") + ); +} + +// --------------------------------------------------------------------------- +// Minimal bech32 + secp256k1 helpers (pure JS, no native deps) +// --------------------------------------------------------------------------- + +const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +const BECH32_GENERATOR = [ + 0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3, +]; + +function bech32Polymod(values: number[]): number { + let chk = 1; + for (const v of values) { + const top = chk >> 25; + chk = ((chk & 0x1ffffff) << 5) ^ v; + for (let i = 0; i < 5; i++) { + if ((top >> i) & 1) chk ^= BECH32_GENERATOR[i]; + } + } + return chk; +} + +function bech32HrpExpand(hrp: string): number[] { + const ret: number[] = []; + for (let i = 0; i < hrp.length; i++) ret.push(hrp.charCodeAt(i) >> 5); + ret.push(0); + for (let i = 0; i < hrp.length; i++) ret.push(hrp.charCodeAt(i) & 31); + return ret; +} + +function bech32CreateChecksum(hrp: string, data: number[]): number[] { + const values = bech32HrpExpand(hrp).concat(data).concat([0, 0, 0, 0, 0, 0]); + const mod = bech32Polymod(values) ^ 1; + const ret: number[] = []; + for (let p = 0; p < 6; p++) ret.push((mod >> (5 * (5 - p))) & 31); + return ret; +} + +function bech32Encode(hrp: string, data: number[]): string { + const combined = data.concat(bech32CreateChecksum(hrp, data)); + let result = hrp + "1"; + for (const b of combined) result += BECH32_CHARSET[b]; + return result; +} + +function bech32Decode(str: string): { hrp: string; data: Uint8Array } | null { + const lower = str.toLowerCase(); + const sep = lower.lastIndexOf("1"); + if (sep < 1 || sep + 7 > lower.length || lower.length > 90) return null; + const hrp = lower.slice(0, sep); + const data: number[] = []; + for (let i = sep + 1; i < lower.length; i++) { + const v = BECH32_CHARSET.indexOf(lower[i]); + if (v === -1) return null; + data.push(v); + } + if (bech32Polymod(bech32HrpExpand(hrp).concat(data)) !== 1) return null; + return { + hrp, + data: new Uint8Array(convertBits(data.slice(0, -6), 5, 8, false)), + }; +} + +function convertBits( + data: number[], + fromBits: number, + toBits: number, + pad: boolean +): number[] { + let acc = 0; + let bits = 0; + const result: number[] = []; + const maxv = (1 << toBits) - 1; + for (const value of data) { + if (value < 0 || value >> fromBits !== 0) throw new Error("Invalid value"); + acc = (acc << fromBits) | value; + bits += fromBits; + while (bits >= toBits) { + bits -= toBits; + result.push((acc >> bits) & maxv); + } + } + if (pad && bits > 0) { + result.push((acc << (toBits - bits)) & maxv); + } else if (bits >= fromBits || ((acc << (toBits - bits)) & maxv)) { + if (!pad && bits >= fromBits) throw new Error("Excessive padding"); + } + return result; +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +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; +} + +// Minimal secp256k1 x-only public key derivation via BigInt +const P = + 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn; +const Gx = + 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n; +const Gy = + 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n; + +type Point = { x: bigint; y: bigint } | null; + +function modPow(base: bigint, exp: bigint, mod: bigint): bigint { + let result = 1n; + base = base % mod; + while (exp > 0n) { + if (exp % 2n === 1n) result = (result * base) % mod; + exp = exp / 2n; + base = (base * base) % mod; + } + return result; +} + +function pointAdd(p1: Point, p2: Point): Point { + if (p1 === null) return p2; + if (p2 === null) return p1; + if (p1.x === p2.x) { + if (p1.y !== p2.y) return null; + const lam = (3n * p1.x * p1.x * modPow(2n * p1.y, P - 2n, P)) % P; + const x = (lam * lam - 2n * p1.x + P + P) % P; + const y = (lam * (p1.x - x) - p1.y + P * 2n) % P; + return { x, y }; + } + const lam = + ((p2.y - p1.y + P * 2n) % P) * + modPow((p2.x - p1.x + P) % P, P - 2n, P) % + P; + const x = (lam * lam - p1.x - p2.x + P * 4n) % P; + const y = (lam * (p1.x - x) - p1.y + P * 4n) % P; + return { x, y }; +} + +function scalarMul(k: bigint): Point { + let result: Point = null; + let addend: Point = { x: Gx, y: Gy }; + while (k > 0n) { + if (k % 2n === 1n) result = pointAdd(result, addend); + addend = pointAdd(addend, addend); + k >>= 1n; + } + return result; +} + +function privateKeyToPublicKeyHex(privKeyBytes: Uint8Array): string { + const k = BigInt("0x" + bytesToHex(privKeyBytes)); + const point = scalarMul(k); + if (!point) throw new Error("Invalid private key"); + return point.x.toString(16).padStart(64, "0"); +} + +function nsecToNpub(nsec: string): string { + const decoded = bech32Decode(nsec); + if (!decoded || decoded.hrp !== "nsec") + throw new Error("Invalid nsec encoding"); + const pubkeyHex = privateKeyToPublicKeyHex(decoded.data); + const pubkeyBytes = hexToBytes(pubkeyHex); + const data5bit = convertBits(Array.from(pubkeyBytes), 8, 5, true); + return bech32Encode("npub", data5bit); +} + +// --------------------------------------------------------------------------- +// NIP-55 Amber helpers (Android only) +// --------------------------------------------------------------------------- + +const AMBER_SCHEME = "nostrsigner:"; +export const AMBER_PACKAGE = "com.greenart7c3.nostrsigner"; + +export type NostrEvent = { + kind: number; + content: string; + tags: string[][]; + created_at: number; + pubkey?: string; +}; + +type PendingSign = { + resolve: (event: NostrEvent) => void; + reject: (err: Error) => void; +}; + +function buildAmberSignUrl(event: NostrEvent, callbackUrl: string): string { + const payload = JSON.stringify(event); + const encoded = utf8ToBase64(payload); + return ( + `${AMBER_SCHEME}${encodeURIComponent(encoded)}` + + `?compressionType=none&returnType=event&type=sign_event` + + `&callbackUrl=${encodeURIComponent(callbackUrl)}` + ); +} + +export async function isAmberInstalled(): Promise { + if (Platform.OS !== "android") return false; + try { + return await Linking.canOpenURL(AMBER_SCHEME); + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Context types +// --------------------------------------------------------------------------- + +export type SigningMethod = "amber" | "nsec"; + +type NostrIdentityContextValue = { + /** Bech32 npub, or null when no identity is connected. */ + npub: string | null; + isConnected: boolean; + /** How the identity was connected (null = not connected). */ + signingMethod: SigningMethod | null; + amberAvailable: boolean; + /** Connect using a raw nsec string. Throws on invalid input. */ + connectWithNsec: (nsec: string) => Promise; + /** Connect via Amber (Android only). Stores npub; signing delegates to Amber. */ + connectWithAmber: () => Promise; + /** Wipe the key from SecureStore and reset identity state. */ + disconnect: () => Promise; + /** + * Sign a Nostr event. + * - nsec method: adds pubkey attribution using the stored key. + * - Amber method: deep-links to Amber and awaits the callback. + */ + signEvent: (event: NostrEvent) => Promise; +}; + +const NostrIdentityContext = createContext( + null +); + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +export function NostrIdentityProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [npub, setNpub] = useState(null); + const [signingMethod, setSigningMethod] = useState( + null + ); + const [amberAvailable, setAmberAvailable] = useState(false); + const pendingSignRef = useRef(null); + + // Check for Amber on mount (Android only) + useEffect(() => { + isAmberInstalled().then(setAmberAvailable); + }, []); + + // Load persisted identity on mount + useEffect(() => { + (async () => { + try { + const stored = await SecureStore.getItemAsync(NOSTR_NSEC_KEY); + if (!stored) return; + const parsed: { nsec?: string; method?: SigningMethod } = + JSON.parse(stored); + if (parsed.nsec && parsed.method) { + // For nsec method, re-derive npub. For amber, stored value IS the npub. + const derivedNpub = + parsed.method === "nsec" + ? nsecToNpub(parsed.nsec) + : parsed.nsec; + setNpub(derivedNpub); + setSigningMethod(parsed.method); + } + } catch { + // Corrupted or missing — silently ignore + } + })(); + }, []); + + // Handle Amber callback deep link (used for sign_event responses) + useEffect(() => { + const handleUrl = ({ url }: { url: string }) => { + if (!url.includes("amber-callback")) return; + const parsed = Linking.parse(url); + const eventParam = + typeof parsed.queryParams?.event === "string" + ? parsed.queryParams.event + : null; + if (!eventParam || !pendingSignRef.current) return; + try { + const decoded = base64ToUtf8(decodeURIComponent(eventParam)); + const signedEvent: NostrEvent = JSON.parse(decoded); + pendingSignRef.current.resolve(signedEvent); + } catch (err) { + pendingSignRef.current.reject( + err instanceof Error + ? err + : new Error("Failed to parse Amber response") + ); + } finally { + pendingSignRef.current = null; + } + }; + + const sub = Linking.addEventListener("url", handleUrl); + return () => sub.remove(); + }, []); + + const connectWithNsec = useCallback(async (nsec: string) => { + const trimmed = nsec.trim(); + // Validate and derive npub (throws on invalid key) + const derivedNpub = nsecToNpub(trimmed); + // Persist: store nsec for local signing capability + await SecureStore.setItemAsync( + NOSTR_NSEC_KEY, + JSON.stringify({ nsec: trimmed, method: "nsec" satisfies SigningMethod }) + ); + setNpub(derivedNpub); + setSigningMethod("nsec"); + }, []); + + const connectWithAmber = useCallback(async () => { + if (Platform.OS !== "android") { + throw new Error("Amber is only available on Android"); + } + if (!(await isAmberInstalled())) { + throw new Error("Amber is not installed"); + } + // Request npub from Amber via NIP-55 get_public_key + const callbackUrl = Linking.createURL("amber-callback"); + const url = + `${AMBER_SCHEME}?type=get_public_key` + + `&callbackUrl=${encodeURIComponent(callbackUrl)}`; + + const npubResult = await new Promise((resolve, reject) => { + const handle: { sub: ReturnType | null } = + { sub: null }; + + const timeout = setTimeout(() => { + handle.sub?.remove(); + reject(new Error("Amber did not respond in time")); + }, 60_000); + + handle.sub = Linking.addEventListener("url", ({ url: incomingUrl }) => { + if (!incomingUrl.includes("amber-callback")) return; + clearTimeout(timeout); + handle.sub?.remove(); + try { + const parsed = Linking.parse(incomingUrl); + const npubParam = + typeof parsed.queryParams?.npub === "string" + ? parsed.queryParams.npub + : null; + if (!npubParam) reject(new Error("Amber did not return an npub")); + else resolve(npubParam); + } catch (err) { + reject( + err instanceof Error + ? err + : new Error("Failed to parse Amber response") + ); + } + }); + + Linking.openURL(url).catch((err) => { + clearTimeout(timeout); + handle.sub?.remove(); + reject(err); + }); + }); + + // Store npub (no raw key — Amber holds it) + await SecureStore.setItemAsync( + NOSTR_NSEC_KEY, + JSON.stringify({ + nsec: npubResult, + method: "amber" satisfies SigningMethod, + }) + ); + setNpub(npubResult); + setSigningMethod("amber"); + }, []); + + const disconnect = useCallback(async () => { + await SecureStore.deleteItemAsync(NOSTR_NSEC_KEY); + setNpub(null); + setSigningMethod(null); + pendingSignRef.current?.reject(new Error("Disconnected")); + pendingSignRef.current = null; + }, []); + + const signEvent = useCallback( + async (event: NostrEvent): Promise => { + if (!signingMethod) throw new Error("No Nostr identity connected"); + + if (signingMethod === "amber") { + if (Platform.OS !== "android") + throw new Error("Amber signing is Android-only"); + if (pendingSignRef.current) + throw new Error("A signing request is already in progress"); + + const callbackUrl = Linking.createURL("amber-callback"); + const amberUrl = buildAmberSignUrl(event, callbackUrl); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + if (pendingSignRef.current) { + pendingSignRef.current = null; + reject(new Error("Amber signing timed out")); + } + }, 60_000); + + pendingSignRef.current = { + resolve: (signed) => { + clearTimeout(timeout); + resolve(signed); + }, + reject: (err) => { + clearTimeout(timeout); + reject(err); + }, + }; + + Linking.openURL(amberUrl).catch((err) => { + clearTimeout(timeout); + pendingSignRef.current = null; + reject(err); + }); + }); + } + + // nsec path — load key from SecureStore and set pubkey attribution + const stored = await SecureStore.getItemAsync(NOSTR_NSEC_KEY); + if (!stored) throw new Error("Key not found in SecureStore"); + const { nsec: storedNsec } = JSON.parse(stored) as { + nsec: string; + method: string; + }; + const decoded = bech32Decode(storedNsec); + if (!decoded || decoded.hrp !== "nsec") + throw new Error("Invalid stored nsec"); + const pubkeyHex = privateKeyToPublicKeyHex(decoded.data); + return { ...event, pubkey: pubkeyHex }; + }, + [signingMethod] + ); + + const value = useMemo( + () => ({ + npub, + isConnected: npub !== null, + signingMethod, + amberAvailable, + connectWithNsec, + connectWithAmber, + disconnect, + signEvent, + }), + [ + npub, + signingMethod, + amberAvailable, + connectWithNsec, + connectWithAmber, + disconnect, + signEvent, + ] + ); + + return ( + + {children} + + ); +} + +export function useNostrIdentity() { + const ctx = useContext(NostrIdentityContext); + if (!ctx) + throw new Error( + "useNostrIdentity must be used within NostrIdentityProvider" + ); + return ctx; +} + +/** Truncates an npub for display, e.g. "npub1abc...xyz" */ +export function truncateNpub(npub: string, headLen = 10, tailLen = 6): string { + if (npub.length <= headLen + tailLen + 3) return npub; + return `${npub.slice(0, headLen)}...${npub.slice(-tailLen)}`; +} diff --git a/artifacts/mobile/package.json b/artifacts/mobile/package.json index c1bd9ae..1f3ffce 100644 --- a/artifacts/mobile/package.json +++ b/artifacts/mobile/package.json @@ -25,6 +25,7 @@ "babel-plugin-react-compiler": "^19.0.0-beta-e993439-20250117", "expo": "~54.0.27", "expo-blur": "~15.0.8", + "expo-secure-store": "~14.0.1", "expo-constants": "~18.0.11", "expo-font": "~14.0.10", "expo-glass-effect": "~0.1.4",