SkateSpots/src/components/auth/AmberCallbackHandler.tsx
SkateSpots a8b4b9fdd0 fix(security): tier 1 — money & auth (C1–C5 partial)
- C1 Marketplace payments now verify the invoice amount: usePayPaymentPointer
  calls assertInvoiceAmount(invoice, amountSats) before paying, so a seller
  can't attach a raw BOLT11 or return an inflated LNURL invoice and have an
  auto-approving wallet pay it.
- C2 NIP-17 DMs now verifyEvent(seal) in all four read paths — the seal is the
  only authentication for a message; a crafted seal could impersonate the sender.
- C3 push-service: bind endpoint ownership (reject subscribe by a different
  pubkey; only the owner may unsubscribe) and reject non-HTTPS/private/loopback
  endpoints (SSRF).
- C4 Amber callback: a bare ?event=<pubkey> is no longer treated as a login;
  the callback must match a pending get_public_key op (both callback pages).
  Also stops logging the pending op payload.
- C5a platform-fee record is now per-pubkey, so an account on a shared device
  can't inherit another's paid status.
- C5b (self-asserted featured ranking) deferred: needs a batch-verification pass
  against provider zap receipts before it can gate ordering.
2026-10-02 21:19:10 +07:00

210 lines
7.5 KiB
TypeScript

/**
* AmberCallbackHandler
*
* This component handles Amber (NIP-55) callbacks at the app level.
* It processes results from Amber after signing operations and login.
*
* For web apps, Amber communication works via URL redirects:
* 1. App opens nostrsigner: URL to request signing
* 2. Amber opens and user approves/rejects
* 3. Amber redirects back to callbackUrl with result in query params
* 4. This component processes the result
*
* PWA NOTE: PWAs can lose localStorage when redirecting to external apps.
* This handler uses multiple fallback strategies:
* 1. Check localStorage, sessionStorage, and IndexedDB for pending operation
* 2. If all storage is lost but result looks like a pubkey, assume it's a login
*/
import { useEffect, useRef, useState } from 'react';
import { useLoginActions } from '@/hooks/useLoginActions';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import {
isAmberCallback,
getAmberCallbackResult,
getAmberPendingOperationAsync,
clearAmberPendingOperation,
cleanAmberCallbackUrl,
getAmberLogin,
reconstructSignedEvent,
} from '@/lib/amber';
export function AmberCallbackHandler() {
const login = useLoginActions();
const { toast } = useToast();
const { t } = useTranslation();
const hasProcessed = useRef(false);
const [debugInfo, setDebugInfo] = useState<string | null>(null);
useEffect(() => {
// If we're already on the /amber-callback route, AmberCallbackPage handles
// the processing — this global handler must stay silent to avoid a race.
const hashPath = window.location.hash.replace(/^#/, '').split('?')[0];
if (hashPath === '/amber-callback') {
return;
}
// Check if this is an Amber callback
const isCallback = isAmberCallback();
if (!isCallback) {
return;
}
// Only process once per page load
if (hasProcessed.current) {
return;
}
hasProcessed.current = true;
// Show debug info temporarily
setDebugInfo(t('settingsUi.amberCallback.processing'));
// Process async to allow IndexedDB lookup
processAmberCallback();
async function processAmberCallback() {
const result = getAmberCallbackResult();
setDebugInfo(t('settingsUi.amberCallback.gotResult', { result: result ? result.substring(0, 16) + '...' : 'null' }));
// Clean up URL immediately to prevent re-processing on refresh
cleanAmberCallbackUrl();
if (!result) {
// User cancelled or Amber failed
clearAmberPendingOperation();
setDebugInfo(t('settingsUi.amberCallback.noResultDebug'));
toast({
title: 'Amber',
description: t('settingsUi.amberCallback.noResultToast'),
variant: 'destructive',
});
return;
}
// Try to get pending operation from any storage
const pendingOp = await getAmberPendingOperationAsync();
// Log only the operation TYPE — never the payload (encrypt/decrypt ops can
// carry plaintext).
console.log('[AmberCallback] Pending operation type:', pendingOp?.type ?? 'none');
const pubkeyRegex = /^[0-9a-fA-F]{64}$/;
// SECURITY: a callback is only valid when it matches an operation this app
// actually started. Never infer a login from a bare `?event=<pubkey>` URL —
// that would let any link switch the user into an arbitrary account.
if (!pendingOp) {
console.warn('[AmberCallback] No pending operation found — ignoring callback');
setDebugInfo(t('settingsUi.amberCallback.sessionLostToast'));
toast({
title: 'Amber',
description: t('settingsUi.amberCallback.sessionLostToast'),
variant: 'destructive',
});
return;
}
// Process based on operation type
switch (pendingOp.type) {
case 'get_public_key': {
console.log('[AmberCallback] Processing get_public_key...');
if (pubkeyRegex.test(result)) {
try {
login.amber(result.toLowerCase());
clearAmberPendingOperation();
console.log('[AmberCallback] Login successful!');
toast({
title: t('settingsUi.amberCallback.loggedInTitle'),
description: t('settingsUi.amberCallback.amberLoginDesc'),
});
} catch (error) {
console.error('[AmberCallback] Amber login failed:', error);
clearAmberPendingOperation();
toast({
title: t('settingsUi.amberCallback.loginFailedTitle'),
description: t('settingsUi.amberCallback.loginFailedDesc'),
variant: 'destructive',
});
}
} else {
console.warn('[AmberCallback] Invalid pubkey format:', result);
clearAmberPendingOperation();
toast({
title: t('settingsUi.amberCallback.loginFailedTitle'),
description: t('settingsUi.amberCallback.invalidPubkeyDesc'),
variant: 'destructive',
});
}
break;
}
case 'sign_event': {
// 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({
title: t('settingsUi.amberCallback.eventSignedTitle'),
description: t('settingsUi.amberCallback.eventSignedDesc'),
});
// Reload the page to re-submit the event
// This is a workaround since we can't resolve the original promise
window.location.reload();
} else {
console.error('Failed to reconstruct signed event from Amber result');
clearAmberPendingOperation();
toast({
title: t('settingsUi.amberCallback.signingFailedTitle'),
description: t('settingsUi.amberCallback.signingFailedDesc'),
variant: 'destructive',
});
}
break;
}
case 'nip04_encrypt':
case 'nip44_encrypt':
case 'nip04_decrypt':
case 'nip44_decrypt': {
// Store encryption/decryption results
sessionStorage.setItem('amber_crypto_result', result);
clearAmberPendingOperation();
toast({
title: t('settingsUi.amberCallback.operationCompleteTitle'),
description: t('settingsUi.amberCallback.operationCompleteDesc', { op: pendingOp.type.replace('_', ' ') }),
});
break;
}
default: {
console.warn('Unknown Amber operation type:', pendingOp.type);
clearAmberPendingOperation();
}
}
}
}, [login, toast, t]);
// Show debug overlay when processing Amber callback
if (debugInfo) {
return (
<div className="fixed top-0 left-0 right-0 z-[9999] bg-yellow-500 text-black px-4 py-2 text-center text-sm font-mono">
🔐 Amber: {debugInfo}
</div>
);
}
return null;
}