335 lines
13 KiB
TypeScript
335 lines
13 KiB
TypeScript
/**
|
|
* 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: <pubkey>, package }
|
|
* signEvent({ eventJson, npub }) → { result: <sig>, 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<AmberIntentNative>('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<void> = Promise.resolve();
|
|
function runAmber<T>(operation: () => Promise<T>): Promise<T> {
|
|
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<string> {
|
|
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<string> | null = null;
|
|
|
|
export function getAmberPublicKeyViaPlugin(): Promise<string> {
|
|
if (publicKeyInFlight) return publicKeyInFlight;
|
|
|
|
const task = (async (): Promise<string> => {
|
|
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<string, string>;
|
|
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<string, Promise<{ result?: string; event?: string; id?: string }>>();
|
|
const cryptoInFlight = new Map<string, Promise<string>>();
|
|
/**
|
|
* 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<string, string>();
|
|
|
|
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<string> {
|
|
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<NostrEvent, 'id' | 'sig'>): Promise<NostrEvent> {
|
|
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<string> {
|
|
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<string> => {
|
|
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<string> =>
|
|
this.crypto('nip04_encrypt', pubkey, plaintext),
|
|
decrypt: async (pubkey: string, ciphertext: string): Promise<string> =>
|
|
this.crypto('nip04_decrypt', pubkey, ciphertext),
|
|
};
|
|
|
|
readonly nip44 = {
|
|
encrypt: async (pubkey: string, plaintext: string): Promise<string> =>
|
|
this.crypto('nip44_encrypt', pubkey, plaintext),
|
|
decrypt: async (pubkey: string, ciphertext: string): Promise<string> =>
|
|
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<boolean> {
|
|
return isNativeAmberAvailable();
|
|
}
|