Co-authored-by: Claude (Opus 4.6) <claude@hermes.local> Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
275 lines
9.5 KiB
TypeScript
275 lines
9.5 KiB
TypeScript
/**
|
|
* NostrContext — Nostr identity management for mobile.
|
|
*
|
|
* Android: NIP-55 Amber deep-link signing (com.greenart7c3.nostrsigner).
|
|
* Opens Amber via the `nostrsigner:` URI scheme to retrieve the user's
|
|
* public key; falls back to the Play Store install prompt when Amber is
|
|
* not installed.
|
|
*
|
|
* iOS / manual fallback: nsec paste-in stored exclusively in Expo SecureStore.
|
|
* The raw key is NEVER written to AsyncStorage, Redux state, or logs.
|
|
*/
|
|
|
|
import React, {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
} from "react";
|
|
import { Linking, Platform } from "react-native";
|
|
import * as SecureStore from "expo-secure-store";
|
|
import { getPublicKey, nip19 } from "nostr-tools";
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
export type NostrSignerType = "amber" | "nsec" | null;
|
|
|
|
export type NostrConnectResult =
|
|
| { success: true }
|
|
| { success: false; error: string };
|
|
|
|
type NostrContextValue = {
|
|
/** bech32 public key (npub1…), null when no identity is loaded */
|
|
npub: string | null;
|
|
/** Raw hex public key, null when no identity is loaded */
|
|
pubkeyHex: string | null;
|
|
/** How the key was connected */
|
|
signerType: NostrSignerType;
|
|
/** True when an identity is loaded */
|
|
nostrConnected: boolean;
|
|
/** True only on Android — Amber integration available */
|
|
canUseAmber: boolean;
|
|
/** Android only: launch Amber to retrieve the user's public key */
|
|
connectWithAmber: () => Promise<void>;
|
|
/** Both platforms: validate & store an nsec; derive and cache the npub */
|
|
connectWithNsec: (nsec: string) => Promise<NostrConnectResult>;
|
|
/** Wipe all Nostr credentials from SecureStore and reset state */
|
|
disconnect: () => Promise<void>;
|
|
};
|
|
|
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
|
|
const SECURE_KEY_NSEC = "nostr.nsec";
|
|
const SECURE_KEY_NPUB = "nostr.npub";
|
|
const SECURE_KEY_SIGNER_TYPE = "nostr.signer_type";
|
|
|
|
/** The deep-link scheme declared in app.json */
|
|
const APP_SCHEME = "mobile";
|
|
/** Path Amber will call back to with the pubkey result */
|
|
const AMBER_CALLBACK_URL = `${APP_SCHEME}://nostr-callback`;
|
|
const AMBER_PACKAGE = "com.greenart7c3.nostrsigner";
|
|
const AMBER_PLAY_STORE_URL =
|
|
"https://play.google.com/store/apps/details?id=com.greenart7c3.nostrsigner";
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
/** Truncate an npub for display: "npub1abcde…xyz12" */
|
|
export function truncateNpub(npub: string): string {
|
|
if (npub.length <= 20) return npub;
|
|
return `${npub.substring(0, 10)}…${npub.substring(npub.length - 5)}`;
|
|
}
|
|
|
|
// ─── Context ──────────────────────────────────────────────────────────────────
|
|
|
|
const NostrContext = createContext<NostrContextValue | null>(null);
|
|
|
|
export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|
const [npub, setNpub] = useState<string | null>(null);
|
|
const [pubkeyHex, setPubkeyHex] = useState<string | null>(null);
|
|
const [signerType, setSignerType] = useState<NostrSignerType>(null);
|
|
|
|
const canUseAmber = Platform.OS === "android";
|
|
|
|
// ── Load persisted identity on mount ──────────────────────────────────────
|
|
|
|
useEffect(() => {
|
|
async function loadIdentity() {
|
|
try {
|
|
const [storedNpub, storedSignerType] = await Promise.all([
|
|
SecureStore.getItemAsync(SECURE_KEY_NPUB),
|
|
SecureStore.getItemAsync(SECURE_KEY_SIGNER_TYPE),
|
|
]);
|
|
|
|
if (storedNpub && storedSignerType) {
|
|
setNpub(storedNpub);
|
|
setSignerType(storedSignerType as NostrSignerType);
|
|
try {
|
|
const decoded = nip19.decode(storedNpub);
|
|
if (decoded.type === "npub") {
|
|
setPubkeyHex(decoded.data as string);
|
|
}
|
|
} catch {
|
|
// npub decode failure — identity still "connected", pubkeyHex stays null
|
|
}
|
|
}
|
|
} catch {
|
|
// SecureStore unavailable (e.g. web build) — proceed without identity
|
|
}
|
|
}
|
|
loadIdentity();
|
|
}, []);
|
|
|
|
// ── Handle Amber callback deep link (Android) ─────────────────────────────
|
|
|
|
useEffect(() => {
|
|
if (!canUseAmber) return;
|
|
|
|
function handleUrl({ url }: { url: string }) {
|
|
if (!url.startsWith(`${APP_SCHEME}://nostr-callback`)) return;
|
|
|
|
try {
|
|
// React Native's URL parsing is not available in all environments;
|
|
// parse manually to avoid importing a polyfill.
|
|
const queryStart = url.indexOf("?");
|
|
if (queryStart === -1) return;
|
|
const params = new URLSearchParams(url.slice(queryStart + 1));
|
|
const result = params.get("result");
|
|
if (!result) return;
|
|
|
|
// Amber returns the hex pubkey in `result`
|
|
let hexKey = result;
|
|
if (result.startsWith("npub1")) {
|
|
const decoded = nip19.decode(result);
|
|
if (decoded.type === "npub") hexKey = decoded.data as string;
|
|
}
|
|
|
|
const derivedNpub = nip19.npubEncode(hexKey);
|
|
|
|
// Persist — no private key stored for Amber flow
|
|
SecureStore.setItemAsync(SECURE_KEY_NPUB, derivedNpub).catch(() => {});
|
|
SecureStore.setItemAsync(SECURE_KEY_SIGNER_TYPE, "amber").catch(() => {});
|
|
|
|
setNpub(derivedNpub);
|
|
setPubkeyHex(hexKey);
|
|
setSignerType("amber");
|
|
} catch {
|
|
// Malformed callback — silently ignore
|
|
}
|
|
}
|
|
|
|
const subscription = Linking.addEventListener("url", handleUrl);
|
|
return () => subscription.remove();
|
|
}, [canUseAmber]);
|
|
|
|
// ── Actions ───────────────────────────────────────────────────────────────
|
|
|
|
const connectWithAmber = useCallback(async () => {
|
|
// NIP-55: request the user's public key from Amber
|
|
const amberUri = `nostrsigner:?type=get_public_key&compressionType=none&returnType=signature&callbackUrl=${encodeURIComponent(AMBER_CALLBACK_URL)}`;
|
|
|
|
let canOpen = false;
|
|
try {
|
|
canOpen = await Linking.canOpenURL(`nostrsigner:`);
|
|
} catch {
|
|
canOpen = false;
|
|
}
|
|
|
|
if (canOpen) {
|
|
await Linking.openURL(amberUri);
|
|
} else {
|
|
// Amber not installed — direct user to Play Store
|
|
await Linking.openURL(AMBER_PLAY_STORE_URL);
|
|
}
|
|
}, []);
|
|
|
|
const connectWithNsec = useCallback(
|
|
async (nsec: string): Promise<NostrConnectResult> => {
|
|
const trimmed = nsec.trim();
|
|
|
|
if (!trimmed.startsWith("nsec1")) {
|
|
return { success: false, error: "Key must start with nsec1" };
|
|
}
|
|
|
|
let decoded: ReturnType<typeof nip19.decode>;
|
|
try {
|
|
decoded = nip19.decode(trimmed);
|
|
} catch {
|
|
return { success: false, error: "Invalid bech32 encoding" };
|
|
}
|
|
|
|
if (decoded.type !== "nsec") {
|
|
return { success: false, error: "Not a valid nsec key" };
|
|
}
|
|
|
|
let hexPubkey: string;
|
|
try {
|
|
const sk = decoded.data as Uint8Array;
|
|
hexPubkey = getPublicKey(sk);
|
|
} catch {
|
|
return { success: false, error: "Could not derive public key" };
|
|
}
|
|
|
|
const derivedNpub = nip19.npubEncode(hexPubkey);
|
|
|
|
try {
|
|
// Store only in SecureStore — never AsyncStorage, never logs
|
|
await SecureStore.setItemAsync(SECURE_KEY_NSEC, trimmed);
|
|
await SecureStore.setItemAsync(SECURE_KEY_NPUB, derivedNpub);
|
|
await SecureStore.setItemAsync(SECURE_KEY_SIGNER_TYPE, "nsec");
|
|
} catch {
|
|
return { success: false, error: "Failed to store key securely" };
|
|
}
|
|
|
|
setNpub(derivedNpub);
|
|
setPubkeyHex(hexPubkey);
|
|
setSignerType("nsec");
|
|
|
|
return { success: true };
|
|
},
|
|
[]
|
|
);
|
|
|
|
const disconnect = useCallback(async () => {
|
|
try {
|
|
await Promise.all([
|
|
SecureStore.deleteItemAsync(SECURE_KEY_NSEC),
|
|
SecureStore.deleteItemAsync(SECURE_KEY_NPUB),
|
|
SecureStore.deleteItemAsync(SECURE_KEY_SIGNER_TYPE),
|
|
]);
|
|
} catch {
|
|
// Best-effort cleanup; reset state regardless
|
|
}
|
|
setNpub(null);
|
|
setPubkeyHex(null);
|
|
setSignerType(null);
|
|
}, []);
|
|
|
|
// ── Context value ─────────────────────────────────────────────────────────
|
|
|
|
const value = useMemo<NostrContextValue>(
|
|
() => ({
|
|
npub,
|
|
pubkeyHex,
|
|
signerType,
|
|
nostrConnected: npub !== null,
|
|
canUseAmber,
|
|
connectWithAmber,
|
|
connectWithNsec,
|
|
disconnect,
|
|
}),
|
|
[
|
|
npub,
|
|
pubkeyHex,
|
|
signerType,
|
|
canUseAmber,
|
|
connectWithAmber,
|
|
connectWithNsec,
|
|
disconnect,
|
|
]
|
|
);
|
|
|
|
return (
|
|
<NostrContext.Provider value={value}>{children}</NostrContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useNostr(): NostrContextValue {
|
|
const ctx = useContext(NostrContext);
|
|
if (!ctx) throw new Error("useNostr must be used within NostrProvider");
|
|
return ctx;
|
|
}
|
|
|
|
export { AMBER_PACKAGE };
|