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:
parent
977d71dc60
commit
f730f0b200
3 changed files with 110 additions and 11 deletions
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue