/** * CapacitorAmberSigner * * Talks to Amber (NIP-55) using the AMETHYST FOREGROUND-INTENT pattern via our * own native `AmberIntent` Capacitor plugin (android-templates/AmberIntentPlugin.java). * * WHY WE REPLACED THE OLD `NostrSignerPlugin` PATH * ------------------------------------------------ * The stock `nostr-signer-capacitor-plugin` (0.0.5) tries Amber's background * ContentProvider FIRST. On GrapheneOS (and other hardened ROMs) that silent * cross-app ContentResolver query is blocked and never returns, and the plugin * does not fall back cleanly to a foreground intent — so signing hung for 90s * and Amber never even appeared. That is the delete/zap/crew hang. * * Amethyst (the reference NIP-55 client) never hits this: it goes STRAIGHT to a * foreground Intent through a properly-registered ActivityResultLauncher, tracks * each request by id, and awaits the Activity result with a timeout. Our * `AmberIntent` plugin does exactly that — the reliable path that OPENS Amber's * Approve/Reject screen and returns the signature, on stock Android AND on * GrapheneOS. Every request is a user-visible Amber popup (the intuitive UX). * * NATIVE API (AmberIntent): * getPublicKey({ permissions? }) → { result: , package } * signEvent({ eventJson, npub }) → { result: , event } * nipXXEncrypt/Decrypt({ payload, pubKey, npub }) → { result } * A user rejection rejects the promise with message "rejected". */ import type { NostrEvent, NostrSigner } from '@nostrify/nostrify'; import { Capacitor, registerPlugin } from '@capacitor/core'; import { nip19 } from 'nostr-tools'; import { reconstructSignedEvent } from '@/lib/amber'; /** Raw native bridge interface for our AmberIntent plugin (Android). */ interface AmberIntentNative { getPublicKey(options?: { permissions?: string }): Promise<{ result?: string; package?: string; id?: string }>; signEvent(options: { eventJson: string; npub: string }): Promise<{ result?: string; event?: string; id?: string }>; nip04Encrypt(options: { payload: string; pubKey: string; npub: string }): Promise<{ result: string }>; nip04Decrypt(options: { payload: string; pubKey: string; npub: string }): Promise<{ result: string }>; nip44Encrypt(options: { payload: string; pubKey: string; npub: string }): Promise<{ result: string }>; nip44Decrypt(options: { payload: string; pubKey: string; npub: string }): Promise<{ result: string }>; } // Lazily build the native bridge proxy once. `registerPlugin` is a pure factory // (builds a proxy, touches no native APIs at call time), so it's safe on web too // — the proxy simply has no implementation there and calls reject. let nativePlugin: AmberIntentNative | null = null; function getNativeSigner(): AmberIntentNative | null { try { if (!Capacitor?.isNativePlatform?.()) return null; } catch { return null; } if (!nativePlugin) { nativePlugin = registerPlugin('AmberIntent'); } return nativePlugin; } /** * Amber is a single foreground Activity. Two concurrent intents can race: one * is answered, the others sit until the 60s native timeout, and the user sees * Amber apparently open twice / behave unpredictably. Every native call goes * through this one-at-a-time queue. The same request is also coalesced below, * so duplicate hook work never asks Amber twice for the same thing. */ let amberTail: Promise = Promise.resolve(); function runAmber(operation: () => Promise): Promise { const run = amberTail.then(operation, operation); amberTail = run.then(() => undefined, () => undefined); return run; } /** Turn Amber's "rejected" rejection into a friendly, catchable message. */ function normalizeAmberError(e: unknown): Error { const msg = (e as Error)?.message ?? String(e); if (/rejected/i.test(msg)) { return new Error('You rejected the request in Amber.'); } return e instanceof Error ? e : new Error(msg); } /** * Amber may compress a large result (gzip, base64, prefixed `Signer1`) — NIP-55 * lets a client opt out with `compressionType=none`, which the native intents now * send, but this is a belt-and-braces decoder for any signer that compresses * anyway. Without it a long `nip44_decrypt` payload (e.g. a long DM) arrives as * `Signer1…` and is handed to `JSON.parse` as garbage. */ async function decodeAmberResult(value: string): Promise { if (!value || !value.startsWith('Signer1')) return value; try { const binary = atob(value.slice('Signer1'.length)); const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip')); return await new Response(stream).text(); } catch { // DecompressionStream unavailable or bad data — return as-is. return value; } } /** * Login: get public key from Amber via the foreground intent. * * The native plugin OMITS the package on get_public_key (per NIP-55) so Android * opens Amber's UI directly — no ContentProvider, no GrapheneOS stall. We * pre-authorize all the permissions the app needs so Amber can (optionally) * remember the user's choice for a smoother, popup-light experience afterwards. */ let publicKeyInFlight: Promise | null = null; export function getAmberPublicKeyViaPlugin(): Promise { if (publicKeyInFlight) return publicKeyInFlight; const task = (async (): Promise => { const plugin = getNativeSigner(); if (!plugin) { throw new Error('Amber signing is only available in the SkateSpots Android app.'); } const permissions = JSON.stringify([ { type: 'get_public_key' }, { type: 'sign_event' }, { type: 'nip04_encrypt' }, { type: 'nip04_decrypt' }, { type: 'nip44_encrypt' }, { type: 'nip44_decrypt' }, ]); let result: { result?: string; package?: string }; try { result = await runAmber(() => plugin.getPublicKey({ permissions })); } catch (e) { throw normalizeAmberError(e); } // JSObject from Capacitor — normalize to a plain object for reliable access. const plain = JSON.parse(JSON.stringify(result)) as Record; const value = await decodeAmberResult(plain.result ?? plain.npub ?? plain.pubkey ?? ''); if (!value) throw new Error('Amber returned empty key'); if (/^[0-9a-f]{64}$/i.test(value)) { return value.toLowerCase(); } if (value.startsWith('npub1')) { const decoded = nip19.decode(value); if (decoded.type !== 'npub') throw new Error('Unexpected key format from Amber'); return decoded.data as string; } throw new Error(`Unexpected Amber response: "${value.slice(0, 30)}"`); })(); publicKeyInFlight = task; void task.finally(() => { if (publicKeyInFlight === task) publicKeyInFlight = null; }).catch(() => undefined); return task; } const signInFlight = new Map>(); const cryptoInFlight = new Map>(); /** * Decrypt-only result cache. Decryption is deterministic for a given * (identity, sender, ciphertext), so a re-render or a second screen can reuse * the plaintext without making Amber ask again. Encrypt results are NOT cached * (NIP-44 encryption is randomized). Memory-only and bounded; encrypted/private * content is also cached by the feature hooks themselves. */ const DECRYPT_CACHE_MAX = 600; const decryptCache = new Map(); function rememberDecrypt(key: string, value: string): void { if (decryptCache.size >= DECRYPT_CACHE_MAX) { const oldest = decryptCache.keys().next().value; if (oldest !== undefined) decryptCache.delete(oldest); } decryptCache.set(key, value); } export class CapacitorAmberSigner implements NostrSigner { readonly pubkey: string; private readonly npub: string; constructor(pubkeyHex: string) { this.pubkey = pubkeyHex; this.npub = nip19.npubEncode(pubkeyHex); } async getPublicKey(): Promise { return this.pubkey; } /** Get the native AmberIntent bridge, or throw a friendly error off-native. */ private plugin(): AmberIntentNative { const plugin = getNativeSigner(); if (!plugin) { throw new Error('Amber signing is only available in the SkateSpots Android app.'); } return plugin; } async signEvent(event: Omit): Promise { const plugin = this.plugin(); const eventJson = JSON.stringify(event); const requestKey = `${this.npub}:${eventJson}`; let res: { result?: string; event?: string; id?: string }; try { const existing = signInFlight.get(requestKey); if (existing) { res = await existing; } else { const task = runAmber(() => plugin.signEvent({ eventJson, npub: this.npub, })); signInFlight.set(requestKey, task); try { res = await task; } finally { signInFlight.delete(requestKey); } } } catch (e) { throw normalizeAmberError(e); } // Amber may return a full event JSON in `event`, or only a bare signature in // `result`. Reconstruct defensively so signature-only responses (common for // kind:5 deletions and kind:1985 labels) still produce a valid, publishable // event. const rawCandidate = res.event && res.event.trim() ? res.event : (res.result ?? ''); const raw = await decodeAmberResult(rawCandidate); const signed = reconstructSignedEvent(raw, JSON.stringify(event), this.pubkey); if (!signed || !signed.sig) { throw new Error( 'Amber returned an incomplete signature. Open Amber, approve the request, then try again.', ); } return signed; } /** * Queue + coalesce one crypto operation. A second identical decrypt/encrypt * while the first is open waits on the same native result instead of opening * a second Amber screen. */ private async crypto( type: 'nip04_encrypt' | 'nip04_decrypt' | 'nip44_encrypt' | 'nip44_decrypt', pubkey: string, payload: string, ): Promise { const key = `${type}:${this.npub}:${pubkey}:${payload}`; if (type.endsWith('_decrypt')) { const cached = decryptCache.get(key); if (cached !== undefined) return cached; } const existing = cryptoInFlight.get(key); if (existing) return existing; const task = (async (): Promise => { try { const plugin = this.plugin(); const { result } = type === 'nip04_encrypt' ? await runAmber(() => plugin.nip04Encrypt({ payload, pubKey: pubkey, npub: this.npub })) : type === 'nip04_decrypt' ? await runAmber(() => plugin.nip04Decrypt({ payload, pubKey: pubkey, npub: this.npub })) : type === 'nip44_encrypt' ? await runAmber(() => plugin.nip44Encrypt({ payload, pubKey: pubkey, npub: this.npub })) : await runAmber(() => plugin.nip44Decrypt({ payload, pubKey: pubkey, npub: this.npub })); const decoded = await decodeAmberResult(result); if (type.endsWith('_decrypt')) rememberDecrypt(key, decoded); return decoded; } catch (e) { throw normalizeAmberError(e); } })(); cryptoInFlight.set(key, task); try { return await task; } finally { cryptoInFlight.delete(key); } } readonly nip04 = { encrypt: async (pubkey: string, plaintext: string): Promise => this.crypto('nip04_encrypt', pubkey, plaintext), decrypt: async (pubkey: string, ciphertext: string): Promise => this.crypto('nip04_decrypt', pubkey, ciphertext), }; readonly nip44 = { encrypt: async (pubkey: string, plaintext: string): Promise => this.crypto('nip44_encrypt', pubkey, plaintext), decrypt: async (pubkey: string, ciphertext: string): Promise => this.crypto('nip44_decrypt', pubkey, ciphertext), }; } /** * True when we're running inside the native Capacitor app AND the native * NostrSigner bridge is usable. This is the ONLY reliable native check — * reading `window.Capacitor` from a component can be false at mount time or in * builds where Capacitor doesn't attach itself to `window`. Always call this at * the moment of the action (e.g. inside the login click handler), not at render. */ export function isNativeAmberAvailable(): boolean { try { return !!Capacitor?.isNativePlatform?.() && !!getNativeSigner(); } catch { return false; } } /** * Is an external signer (Amber) usable on this device? * * We no longer do an explicit package-visibility check: on Android 11+/GrapheneOS * that check is unreliable (it can report false even when Amber IS installed), * and the login flow already attempts the foreground intent directly and surfaces * a clear error if Amber can't be opened. So this simply mirrors native * availability of the AmberIntent bridge. */ export async function isAmberInstalled(): Promise { return isNativeAmberAvailable(); }