SkateSpots/src/lib/capacitorAmberSigner.ts
SkateSpots cc7eb58828 fix(amber): disable result compression (native) + decode Signer1 fallback
The web/PWA Amber path set compressionType=none; the native intents set neither
compressionType nor returnType, and the JS gzip decoder was never called. NIP-55
lets Amber gzip a large result (Signer1... base64), so a long nip44_decrypt (a
long DM) could arrive compressed and be parsed as garbage. Native intents now
send compressionType=none; the JS signer also gunzips any Signer1 result. Timeout
message no longer wrongly says Amber may not be installed.
2026-10-13 21:05:29 +07:00

249 lines
9.7 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;
}
/** 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.
*/
export async function getAmberPublicKeyViaPlugin(): 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 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)}"`);
}
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();
let res: { result?: string; event?: string };
try {
res = await plugin.signEvent({
eventJson: JSON.stringify(event),
npub: this.npub,
});
} 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;
}
readonly nip04 = {
encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
try {
const { result } = await this.plugin().nip04Encrypt({ payload: plaintext, pubKey: pubkey, npub: this.npub });
return decodeAmberResult(result);
} catch (e) { throw normalizeAmberError(e); }
},
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
try {
const { result } = await this.plugin().nip04Decrypt({ payload: ciphertext, pubKey: pubkey, npub: this.npub });
return decodeAmberResult(result);
} catch (e) { throw normalizeAmberError(e); }
},
};
readonly nip44 = {
encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
try {
const { result } = await this.plugin().nip44Encrypt({ payload: plaintext, pubKey: pubkey, npub: this.npub });
return decodeAmberResult(result);
} catch (e) { throw normalizeAmberError(e); }
},
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
try {
const { result } = await this.plugin().nip44Decrypt({ payload: ciphertext, pubKey: pubkey, npub: this.npub });
return decodeAmberResult(result);
} catch (e) { throw normalizeAmberError(e); }
},
};
}
/**
* 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();
}