diff --git a/AGENTS.md b/AGENTS.md index 374cf50..8d48a54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -543,6 +543,23 @@ session with **no other memory of this project**. So: `lib/cordn/{client,crypto}.ts` are **kept but unwired** (marked in their headers) — re-wiring is a deliberate decision, not a one-liner, because it split chat history. Group reads widened to **500** wraps (1:1 stays 200). + **Amber audit (2026-10-13): result COMPRESSION.** The web/PWA path always sent + `compressionType=none`; the **native** intents sent 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 — a "sometimes" + failure. Fixed both ends: `AmberIntentPlugin` now sends + `compressionType=none` on every intent (template → `android/` at build), and + `capacitorAmberSigner` gunzips any `Signer1…` result anyway. Also: the timeout + message no longer wrongly blames "Amber is not installed". **Verified sound:** + MainActivity registers the `ActivityResultLauncher` before `super.onCreate` + and routes results to `AmberIntentPlugin.handleResult` (id-matched, + single-pending fallback); `reconstructSignedEvent` verifies the signature; + rejections normalize; `isInteractiveSigner` gates background decrypts; the only + multi-signer loops are sequential now. ⚠️ **Web/PWA Amber is inherently + degraded**: sign_event relaunches via a page reload and encrypt/decrypt results + can't resume the original promise after the redirect, so **web Amber crypto + doesn't complete** (use an extension/bunker on web). Native is unaffected. **Persisted DM decrypt cache (2026-10-13):** `src/lib/dmDecryptCache.ts` (Dexie `SkateSpotsDmCacheDB`, keyed `viewerPk:wrapId`, bounded 4000/account, pruned oldest-first on hydrate) is used by `decryptGiftWrap` — so a message is diff --git a/android-templates/AmberIntentPlugin.java b/android-templates/AmberIntentPlugin.java index 13c738f..104a6bf 100644 --- a/android-templates/AmberIntentPlugin.java +++ b/android-templates/AmberIntentPlugin.java @@ -132,6 +132,10 @@ public class AmberIntentPlugin extends Plugin { String permissions = call.getString("permissions", ""); Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:")); intent.putExtra("type", "get_public_key"); + // NIP-55: opt out of result gzip compression. Without this a large result + // (e.g. a long nip44_decrypt payload) comes back as `Signer1…` base64, + // which the JS would parse as garbage. Matches the web/PWA path. + intent.putExtra("compressionType", "none"); if (permissions != null && !permissions.isEmpty()) { intent.putExtra("permissions", permissions); } @@ -147,6 +151,7 @@ public class AmberIntentPlugin extends Plugin { intent.setPackage(AMBER_PACKAGE); intent.putExtra("type", "sign_event"); intent.putExtra("current_user", npub); + intent.putExtra("compressionType", "none"); launch(call, intent, "signEvent"); } @@ -168,6 +173,7 @@ public class AmberIntentPlugin extends Plugin { intent.putExtra("type", type); intent.putExtra("pubKey", pubKey); intent.putExtra("current_user", npub); + intent.putExtra("compressionType", "none"); launch(call, intent, type); } @@ -193,7 +199,7 @@ public class AmberIntentPlugin extends Plugin { PendingCall pc; synchronized (pending) { pc = pending.remove(id); } if (pc != null) { - pc.call.reject("Amber didn't respond. Make sure Amber is installed and try again."); + pc.call.reject("Amber didn't respond in time. Try again."); } } }; diff --git a/src/lib/capacitorAmberSigner.ts b/src/lib/capacitorAmberSigner.ts index 59f950e..d9cb08e 100644 --- a/src/lib/capacitorAmberSigner.ts +++ b/src/lib/capacitorAmberSigner.ts @@ -66,6 +66,26 @@ function normalizeAmberError(e: unknown): Error { 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. * @@ -98,7 +118,7 @@ export async function getAmberPublicKeyViaPlugin(): Promise { // JSObject from Capacitor — normalize to a plain object for reliable access. const plain = JSON.parse(JSON.stringify(result)) as Record; - const value = plain.result ?? plain.npub ?? plain.pubkey ?? ''; + const value = await decodeAmberResult(plain.result ?? plain.npub ?? plain.pubkey ?? ''); if (!value) throw new Error('Amber returned empty key'); @@ -153,11 +173,13 @@ export class CapacitorAmberSigner implements NostrSigner { // `result`. Reconstruct defensively so signature-only responses (common for // kind:5 deletions and kind:1985 labels) still produce a valid, publishable // event. - const raw = + 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( @@ -171,13 +193,13 @@ export class CapacitorAmberSigner implements NostrSigner { encrypt: async (pubkey: string, plaintext: string): Promise => { try { const { result } = await this.plugin().nip04Encrypt({ payload: plaintext, pubKey: pubkey, npub: this.npub }); - return result; + return decodeAmberResult(result); } catch (e) { throw normalizeAmberError(e); } }, decrypt: async (pubkey: string, ciphertext: string): Promise => { try { const { result } = await this.plugin().nip04Decrypt({ payload: ciphertext, pubKey: pubkey, npub: this.npub }); - return result; + return decodeAmberResult(result); } catch (e) { throw normalizeAmberError(e); } }, }; @@ -186,13 +208,13 @@ export class CapacitorAmberSigner implements NostrSigner { encrypt: async (pubkey: string, plaintext: string): Promise => { try { const { result } = await this.plugin().nip44Encrypt({ payload: plaintext, pubKey: pubkey, npub: this.npub }); - return result; + return decodeAmberResult(result); } catch (e) { throw normalizeAmberError(e); } }, decrypt: async (pubkey: string, ciphertext: string): Promise => { try { const { result } = await this.plugin().nip44Decrypt({ payload: ciphertext, pubKey: pubkey, npub: this.npub }); - return result; + return decodeAmberResult(result); } catch (e) { throw normalizeAmberError(e); } }, };