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.
This commit is contained in:
SkateSpots 2026-10-13 21:05:29 +07:00
parent 704714583f
commit cc7eb58828
3 changed files with 52 additions and 7 deletions

View file

@ -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 `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 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). 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 **Persisted DM decrypt cache (2026-10-13):** `src/lib/dmDecryptCache.ts` (Dexie
`SkateSpotsDmCacheDB`, keyed `viewerPk:wrapId`, bounded 4000/account, pruned `SkateSpotsDmCacheDB`, keyed `viewerPk:wrapId`, bounded 4000/account, pruned
oldest-first on hydrate) is used by `decryptGiftWrap` — so a message is oldest-first on hydrate) is used by `decryptGiftWrap` — so a message is

View file

@ -132,6 +132,10 @@ public class AmberIntentPlugin extends Plugin {
String permissions = call.getString("permissions", ""); String permissions = call.getString("permissions", "");
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:")); Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:"));
intent.putExtra("type", "get_public_key"); 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()) { if (permissions != null && !permissions.isEmpty()) {
intent.putExtra("permissions", permissions); intent.putExtra("permissions", permissions);
} }
@ -147,6 +151,7 @@ public class AmberIntentPlugin extends Plugin {
intent.setPackage(AMBER_PACKAGE); intent.setPackage(AMBER_PACKAGE);
intent.putExtra("type", "sign_event"); intent.putExtra("type", "sign_event");
intent.putExtra("current_user", npub); intent.putExtra("current_user", npub);
intent.putExtra("compressionType", "none");
launch(call, intent, "signEvent"); launch(call, intent, "signEvent");
} }
@ -168,6 +173,7 @@ public class AmberIntentPlugin extends Plugin {
intent.putExtra("type", type); intent.putExtra("type", type);
intent.putExtra("pubKey", pubKey); intent.putExtra("pubKey", pubKey);
intent.putExtra("current_user", npub); intent.putExtra("current_user", npub);
intent.putExtra("compressionType", "none");
launch(call, intent, type); launch(call, intent, type);
} }
@ -193,7 +199,7 @@ public class AmberIntentPlugin extends Plugin {
PendingCall pc; PendingCall pc;
synchronized (pending) { pc = pending.remove(id); } synchronized (pending) { pc = pending.remove(id); }
if (pc != null) { 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.");
} }
} }
}; };

View file

@ -66,6 +66,26 @@ function normalizeAmberError(e: unknown): Error {
return e instanceof Error ? e : new Error(msg); 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. * Login: get public key from Amber via the foreground intent.
* *
@ -98,7 +118,7 @@ export async function getAmberPublicKeyViaPlugin(): Promise<string> {
// JSObject from Capacitor — normalize to a plain object for reliable access. // JSObject from Capacitor — normalize to a plain object for reliable access.
const plain = JSON.parse(JSON.stringify(result)) as Record<string, string>; const plain = JSON.parse(JSON.stringify(result)) as Record<string, string>;
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'); 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 // `result`. Reconstruct defensively so signature-only responses (common for
// kind:5 deletions and kind:1985 labels) still produce a valid, publishable // kind:5 deletions and kind:1985 labels) still produce a valid, publishable
// event. // event.
const raw = const rawCandidate =
res.event && res.event.trim() res.event && res.event.trim()
? res.event ? res.event
: (res.result ?? ''); : (res.result ?? '');
const raw = await decodeAmberResult(rawCandidate);
const signed = reconstructSignedEvent(raw, JSON.stringify(event), this.pubkey); const signed = reconstructSignedEvent(raw, JSON.stringify(event), this.pubkey);
if (!signed || !signed.sig) { if (!signed || !signed.sig) {
throw new Error( throw new Error(
@ -171,13 +193,13 @@ export class CapacitorAmberSigner implements NostrSigner {
encrypt: async (pubkey: string, plaintext: string): Promise<string> => { encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
try { try {
const { result } = await this.plugin().nip04Encrypt({ payload: plaintext, pubKey: pubkey, npub: this.npub }); const { result } = await this.plugin().nip04Encrypt({ payload: plaintext, pubKey: pubkey, npub: this.npub });
return result; return decodeAmberResult(result);
} catch (e) { throw normalizeAmberError(e); } } catch (e) { throw normalizeAmberError(e); }
}, },
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => { decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
try { try {
const { result } = await this.plugin().nip04Decrypt({ payload: ciphertext, pubKey: pubkey, npub: this.npub }); const { result } = await this.plugin().nip04Decrypt({ payload: ciphertext, pubKey: pubkey, npub: this.npub });
return result; return decodeAmberResult(result);
} catch (e) { throw normalizeAmberError(e); } } catch (e) { throw normalizeAmberError(e); }
}, },
}; };
@ -186,13 +208,13 @@ export class CapacitorAmberSigner implements NostrSigner {
encrypt: async (pubkey: string, plaintext: string): Promise<string> => { encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
try { try {
const { result } = await this.plugin().nip44Encrypt({ payload: plaintext, pubKey: pubkey, npub: this.npub }); const { result } = await this.plugin().nip44Encrypt({ payload: plaintext, pubKey: pubkey, npub: this.npub });
return result; return decodeAmberResult(result);
} catch (e) { throw normalizeAmberError(e); } } catch (e) { throw normalizeAmberError(e); }
}, },
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => { decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
try { try {
const { result } = await this.plugin().nip44Decrypt({ payload: ciphertext, pubKey: pubkey, npub: this.npub }); const { result } = await this.plugin().nip44Decrypt({ payload: ciphertext, pubKey: pubkey, npub: this.npub });
return result; return decodeAmberResult(result);
} catch (e) { throw normalizeAmberError(e); } } catch (e) { throw normalizeAmberError(e); }
}, },
}; };