Harden all Amber signing paths against empty-id / bare-signature responses

Extends the admin-delete hang fix across the whole app. Audited every
signing route:

- Native (CapacitorAmberSigner): already fixed — computes id locally.
- Web/PWA (AmberSigner redirect flow): the callback handlers did a raw
  JSON.parse(result), but Amber returns a BARE signature (returnType=
  'signature'), so this threw or produced an event with an empty id that
  stalls the relay — the same "stuck" defect as native. Added a shared
  reconstructSignedEvent() helper that rebuilds the event from the stored
  template + signature and computes the id via getEventHash. Applied in
  both AmberCallbackPage and AmberCallbackHandler.
- NIP-17 DMs, zap requests, vouches, crew invites, membership, etc. all
  sign via user.signer.signEvent and therefore inherit the fix.
- nsec / bunker / extension signers use Nostrify base classes (compute
  ids internally) — unaffected.
- Native nip04/nip44 encrypt/decrypt are timeout-protected — no hang.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-03 22:05:25 +07:00
parent 977d71dc60
commit f730f0b200
3 changed files with 110 additions and 11 deletions

View file

@ -25,6 +25,8 @@ import {
getAmberPendingOperationAsync, getAmberPendingOperationAsync,
clearAmberPendingOperation, clearAmberPendingOperation,
cleanAmberCallbackUrl, cleanAmberCallbackUrl,
getAmberLogin,
reconstructSignedEvent,
} from '@/lib/amber'; } from '@/lib/amber';
export function AmberCallbackHandler() { export function AmberCallbackHandler() {
@ -163,12 +165,18 @@ export function AmberCallbackHandler() {
} }
case 'sign_event': { case 'sign_event': {
// Store the signed event result for the caller to retrieve // Store the signed event result for the caller to retrieve.
// This is tricky because the original caller's promise can't be resolved // Amber returns a BARE signature (returnType='signature'), so we
// after a page redirect. For now, we'll store it and show a toast. // reconstruct the full event from the original template + signature
try { // and compute its id locally — a raw JSON.parse(result) would throw on
const signedEvent = JSON.parse(result); // a bare signature or produce an event with an empty id that stalls
// Store in sessionStorage for potential retrieval // the relay. See reconstructSignedEvent() for details.
const signedEvent = reconstructSignedEvent(
result,
pendingOp.eventJson,
getAmberLogin() ?? '',
);
if (signedEvent) {
sessionStorage.setItem('amber_signed_event', JSON.stringify(signedEvent)); sessionStorage.setItem('amber_signed_event', JSON.stringify(signedEvent));
clearAmberPendingOperation(); clearAmberPendingOperation();
toast({ toast({
@ -178,8 +186,8 @@ export function AmberCallbackHandler() {
// Reload the page to re-submit the event // Reload the page to re-submit the event
// This is a workaround since we can't resolve the original promise // This is a workaround since we can't resolve the original promise
window.location.reload(); window.location.reload();
} catch (e) { } else {
console.error('Failed to parse signed event:', e); console.error('Failed to reconstruct signed event from Amber result');
clearAmberPendingOperation(); clearAmberPendingOperation();
toast({ toast({
title: 'Signing failed', title: 'Signing failed',

View file

@ -1,4 +1,5 @@
import type { NostrEvent, NostrSigner } from '@nostrify/nostrify'; import type { NostrEvent, NostrSigner } from '@nostrify/nostrify';
import { getEventHash } from 'nostr-tools';
/** /**
* Pending Amber operation stored in multiple places for PWA resilience. * Pending Amber operation stored in multiple places for PWA resilience.
@ -178,6 +179,87 @@ export function clearAmberLogin(): void {
localStorage.removeItem(AMBER_LOGIN_KEY); localStorage.removeItem(AMBER_LOGIN_KEY);
} }
/**
* Reconstruct a fully-formed, valid signed event from Amber's callback result.
*
* WHY THIS EXISTS
* ---------------
* We ask Amber with `returnType: 'signature'`, so on most Amber builds the
* callback `result` is a BARE 128-char hex signature — NOT a full event JSON.
* The old callback handlers did `JSON.parse(result)`, which either threw on the
* bare signature ("Failed to process signed event") or, when a build returned
* partial JSON, produced an event with an empty `id` that stalls the relay
* forever. This is the same defect that hung admin deletes on native.
*
* The event id is a deterministic NIP-01 hash of the signed fields, so we
* reconstruct the event from the ORIGINAL unsigned template (persisted as
* `eventJson` on the pending op) plus the returned signature, and compute the
* id ourselves. Handles every Amber response shape:
* - bare signature string
* - full signed-event JSON
* - partial JSON missing `id`
*
* @param result the raw callback payload from Amber (signature or JSON)
* @param eventJson the original unsigned event JSON we sent to Amber (if known)
* @param pubkey the signer's pubkey (Amber's key)
* @returns a valid NostrEvent, or null if it cannot be reconstructed
*/
export function reconstructSignedEvent(
result: string,
eventJson: string | undefined,
pubkey: string,
): NostrEvent | null {
const trimmed = (result ?? '').trim();
if (!trimmed) return null;
// Case 1: Amber returned full (or partial) event JSON.
let parsed: Partial<NostrEvent> | null = null;
if (trimmed.startsWith('{')) {
try {
parsed = JSON.parse(trimmed) as Partial<NostrEvent>;
} catch {
parsed = null;
}
}
// Case 2: Amber returned a bare hex signature (returnType='signature').
const looksLikeSig = /^[0-9a-fA-F]{128}$/.test(trimmed);
// Seed the base fields from the original template we sent to Amber.
let template: Partial<NostrEvent> = {};
if (eventJson) {
try {
template = JSON.parse(eventJson) as Partial<NostrEvent>;
} catch {
template = {};
}
}
const kind = parsed?.kind ?? template.kind;
const content = parsed?.content ?? template.content;
const tags = parsed?.tags ?? template.tags;
const created_at = parsed?.created_at ?? template.created_at;
const eventPubkey = parsed?.pubkey ?? template.pubkey ?? pubkey;
const sig = parsed?.sig ?? (looksLikeSig ? trimmed : undefined);
// Without these we cannot produce a valid event.
if (
kind === undefined ||
content === undefined ||
tags === undefined ||
created_at === undefined ||
!sig
) {
return null;
}
const base = { kind, content, tags, created_at, pubkey: eventPubkey };
const id =
parsed?.id && parsed.id.length === 64 ? parsed.id : getEventHash(base);
return { ...base, id, sig };
}
/** /**
* Extract search params from both the real query string AND the hash fragment. * Extract search params from both the real query string AND the hash fragment.
* *

View file

@ -18,6 +18,8 @@ import {
getAmberPendingOperationAsync, getAmberPendingOperationAsync,
clearAmberPendingOperation, clearAmberPendingOperation,
cleanAmberCallbackUrl, cleanAmberCallbackUrl,
getAmberLogin,
reconstructSignedEvent,
} from '@/lib/amber'; } from '@/lib/amber';
import { CheckCircle2, XCircle, Loader2, Home } from 'lucide-react'; import { CheckCircle2, XCircle, Loader2, Home } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -78,15 +80,22 @@ export default function AmberCallbackPage() {
// ── sign_event ────────────────────────────────────────────────────── // ── sign_event ──────────────────────────────────────────────────────
if (pendingOp.type === 'sign_event') { if (pendingOp.type === 'sign_event') {
try { // Amber returns a BARE signature (returnType='signature'), so we
const signedEvent = JSON.parse(result); // reconstruct the full event from the original template + signature and
// compute its id locally. See reconstructSignedEvent() for the why.
const signedEvent = reconstructSignedEvent(
result,
pendingOp.eventJson,
getAmberLogin() ?? '',
);
if (signedEvent) {
localStorage.setItem('amber_signed_event', JSON.stringify(signedEvent)); localStorage.setItem('amber_signed_event', JSON.stringify(signedEvent));
clearAmberPendingOperation(); clearAmberPendingOperation();
setStatus('success'); setStatus('success');
setMessage('Event signed! Returning...'); setMessage('Event signed! Returning...');
toast({ title: 'Event signed', description: 'Your event was signed by Amber.' }); toast({ title: 'Event signed', description: 'Your event was signed by Amber.' });
redirectBack(true); redirectBack(true);
} catch { } else {
setStatus('error'); setStatus('error');
setMessage('Failed to process signed event from Amber.'); setMessage('Failed to process signed event from Amber.');
clearAmberPendingOperation(); clearAmberPendingOperation();