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:
parent
704714583f
commit
cc7eb58828
3 changed files with 52 additions and 7 deletions
17
AGENTS.md
17
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
|
`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
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue