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,
|
||||
clearAmberPendingOperation,
|
||||
cleanAmberCallbackUrl,
|
||||
getAmberLogin,
|
||||
reconstructSignedEvent,
|
||||
} from '@/lib/amber';
|
||||
|
||||
export function AmberCallbackHandler() {
|
||||
|
|
@ -163,12 +165,18 @@ export function AmberCallbackHandler() {
|
|||
}
|
||||
|
||||
case 'sign_event': {
|
||||
// Store the signed event result for the caller to retrieve
|
||||
// This is tricky because the original caller's promise can't be resolved
|
||||
// after a page redirect. For now, we'll store it and show a toast.
|
||||
try {
|
||||
const signedEvent = JSON.parse(result);
|
||||
// Store in sessionStorage for potential retrieval
|
||||
// Store the signed event result for the caller to retrieve.
|
||||
// Amber returns a BARE signature (returnType='signature'), so we
|
||||
// reconstruct the full event from the original template + signature
|
||||
// and compute its id locally — a raw JSON.parse(result) would throw on
|
||||
// a bare signature or produce an event with an empty id that stalls
|
||||
// the relay. See reconstructSignedEvent() for details.
|
||||
const signedEvent = reconstructSignedEvent(
|
||||
result,
|
||||
pendingOp.eventJson,
|
||||
getAmberLogin() ?? '',
|
||||
);
|
||||
if (signedEvent) {
|
||||
sessionStorage.setItem('amber_signed_event', JSON.stringify(signedEvent));
|
||||
clearAmberPendingOperation();
|
||||
toast({
|
||||
|
|
@ -178,8 +186,8 @@ export function AmberCallbackHandler() {
|
|||
// Reload the page to re-submit the event
|
||||
// This is a workaround since we can't resolve the original promise
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
console.error('Failed to parse signed event:', e);
|
||||
} else {
|
||||
console.error('Failed to reconstruct signed event from Amber result');
|
||||
clearAmberPendingOperation();
|
||||
toast({
|
||||
title: 'Signing failed',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { NostrEvent, NostrSigner } from '@nostrify/nostrify';
|
||||
import { getEventHash } from 'nostr-tools';
|
||||
|
||||
/**
|
||||
* Pending Amber operation stored in multiple places for PWA resilience.
|
||||
|
|
@ -178,6 +179,87 @@ export function clearAmberLogin(): void {
|
|||
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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import {
|
|||
getAmberPendingOperationAsync,
|
||||
clearAmberPendingOperation,
|
||||
cleanAmberCallbackUrl,
|
||||
getAmberLogin,
|
||||
reconstructSignedEvent,
|
||||
} from '@/lib/amber';
|
||||
import { CheckCircle2, XCircle, Loader2, Home } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -78,15 +80,22 @@ export default function AmberCallbackPage() {
|
|||
|
||||
// ── sign_event ──────────────────────────────────────────────────────
|
||||
if (pendingOp.type === 'sign_event') {
|
||||
try {
|
||||
const signedEvent = JSON.parse(result);
|
||||
// Amber returns a BARE signature (returnType='signature'), so we
|
||||
// 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));
|
||||
clearAmberPendingOperation();
|
||||
setStatus('success');
|
||||
setMessage('Event signed! Returning...');
|
||||
toast({ title: 'Event signed', description: 'Your event was signed by Amber.' });
|
||||
redirectBack(true);
|
||||
} catch {
|
||||
} else {
|
||||
setStatus('error');
|
||||
setMessage('Failed to process signed event from Amber.');
|
||||
clearAmberPendingOperation();
|
||||
|
|
|
|||
Loading…
Reference in a new issue