fix: gate Amber inbox decryption and expire redirect ops

This commit is contained in:
SkateSpots 2026-10-31 17:17:08 +07:00
parent 5acaa3b2b4
commit 53cad29c7e
8 changed files with 95 additions and 14 deletions

View file

@ -180,7 +180,8 @@ export function AmberCallbackHandler() {
case 'nip04_decrypt': case 'nip04_decrypt':
case 'nip44_decrypt': { case 'nip44_decrypt': {
// Store encryption/decryption results // Store encryption/decryption results
sessionStorage.setItem('amber_crypto_result', result); // The result is delivered through the callback promise/flow; do not
// leave private plaintext in browser storage.
clearAmberPendingOperation(); clearAmberPendingOperation();
toast({ toast({
title: t('settingsUi.amberCallback.operationCompleteTitle'), title: t('settingsUi.amberCallback.operationCompleteTitle'),

View file

@ -738,7 +738,8 @@ export function useConversation(otherPubkey: string) {
/** /**
* Hook to fetch all conversations * Hook to fetch all conversations
*/ */
export function useConversations() { export function useConversations(options: { enabled?: boolean } = {}) {
const callerEnabled = options.enabled ?? true;
const { nostr } = useNostr(); const { nostr } = useNostr();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { logins } = useNostrLogin(); const { logins } = useNostrLogin();
@ -843,7 +844,7 @@ export function useConversations() {
); );
return { conversations: sorted, undecryptable, wraps: uniqueEvents.length }; return { conversations: sorted, undecryptable, wraps: uniqueEvents.length };
}, },
enabled: nip44Available, enabled: nip44Available && callerEnabled,
...amberSafeQueryOptions(isInteractiveSigner(user, logins)), ...amberSafeQueryOptions(isInteractiveSigner(user, logins)),
}); });
} }
@ -860,7 +861,8 @@ export function useConversations() {
* the protocols to adopt if those guarantees are required later. * the protocols to adopt if those guarantees are required later.
* See GROUP_MESSAGING.md for the full decision and upgrade paths. * See GROUP_MESSAGING.md for the full decision and upgrade paths.
*/ */
export function useGroupConversations() { export function useGroupConversations(options: { enabled?: boolean } = {}) {
const callerEnabled = options.enabled ?? true;
const { nostr } = useNostr(); const { nostr } = useNostr();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { logins } = useNostrLogin(); const { logins } = useNostrLogin();
@ -943,7 +945,7 @@ export function useGroupConversations() {
(a, b) => (b.lastMessage?.createdAt || 0) - (a.lastMessage?.createdAt || 0), (a, b) => (b.lastMessage?.createdAt || 0) - (a.lastMessage?.createdAt || 0),
); );
}, },
enabled: nip44Available, enabled: nip44Available && callerEnabled,
...amberSafeQueryOptions(isInteractiveSigner(user, logins)), ...amberSafeQueryOptions(isInteractiveSigner(user, logins)),
}); });
} }

View file

@ -4093,6 +4093,9 @@ socialUi: {
runBy: 'Coordinator run by', runBy: 'Coordinator run by',
}, },
dms: { dms: {
amberUnlockTitle: 'Unlock encrypted messages',
amberUnlockBody: 'Amber asks for approval for each message it decrypts. Load recent conversations only when you are ready — opening a thread may still ask a few times.',
amberUnlockCta: 'Unlock messages',
securityReminder: 'Security reminder', securityReminder: 'Security reminder',
phishingStrong: 'SkateSpots will never DM you', phishingStrong: 'SkateSpots will never DM you',
phishingRest: "asking to verify your account, share your keys, or send payments. If someone claims to be SkateSpots support via DM — it's a scam.", phishingRest: "asking to verify your account, share your keys, or send payments. If someone claims to be SkateSpots support via DM — it's a scam.",

View file

@ -4093,6 +4093,9 @@ socialUi: {
runBy: 'Coordinador gestionado por', runBy: 'Coordinador gestionado por',
}, },
dms: { dms: {
amberUnlockTitle: 'Desbloquear mensajes cifrados',
amberUnlockBody: 'Amber pide aprobación por cada mensaje que descifra. Carga las conversaciones recientes cuando quieras; abrir un hilo aún puede pedirte varias aprobaciones.',
amberUnlockCta: 'Desbloquear mensajes',
securityReminder: 'Recordatorio de seguridad', securityReminder: 'Recordatorio de seguridad',
phishingStrong: 'SkateSpots nunca te enviará un MD', phishingStrong: 'SkateSpots nunca te enviará un MD',
phishingRest: 'pidiéndote que verifiques tu cuenta, compartas tus claves o envíes pagos. Si alguien dice ser soporte de SkateSpots por MD — es una estafa.', phishingRest: 'pidiéndote que verifiques tu cuenta, compartas tus claves o envíes pagos. Si alguien dice ser soporte de SkateSpots por MD — es una estafa.',

25
src/lib/amber.test.ts Normal file
View file

@ -0,0 +1,25 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getAmberPendingOperation, setAmberPendingOperation } from './amber';
describe('Amber pending redirect operations', () => {
afterEach(() => {
vi.restoreAllMocks();
localStorage.clear();
sessionStorage.clear();
});
it('accepts a fresh operation', () => {
setAmberPendingOperation({ type: 'get_public_key', id: 'fresh', timestamp: Date.now() });
expect(getAmberPendingOperation()?.id).toBe('fresh');
});
it('rejects and clears a stale callback operation', () => {
setAmberPendingOperation({
type: 'get_public_key',
id: 'stale',
timestamp: Date.now() - 11 * 60 * 1000,
});
expect(getAmberPendingOperation()).toBeNull();
expect(localStorage.getItem('amber_pending_operation')).toBeNull();
});
});

View file

@ -23,6 +23,8 @@ export interface AmberPendingOperation {
const AMBER_PENDING_KEY = 'amber_pending_operation'; const AMBER_PENDING_KEY = 'amber_pending_operation';
const AMBER_LOGIN_KEY = 'amber_login'; const AMBER_LOGIN_KEY = 'amber_login';
/** Pending redirect operations are short-lived; never accept a stale callback. */
const AMBER_PENDING_TTL_MS = 10 * 60 * 1000;
/** /**
* Store pending operation info for when Amber returns. * Store pending operation info for when Amber returns.
@ -63,6 +65,10 @@ export function setAmberPendingOperation(operation: AmberPendingOperation): void
} }
} }
function isExpiredPending(op: AmberPendingOperation | null): boolean {
return !!op && (!Number.isFinite(op.timestamp) || Date.now() - op.timestamp > AMBER_PENDING_TTL_MS);
}
/** /**
* Get pending operation from any available storage. * Get pending operation from any available storage.
*/ */
@ -81,7 +87,12 @@ export function getAmberPendingOperation(): AmberPendingOperation | null {
if (!stored) return null; if (!stored) return null;
try { try {
return JSON.parse(stored) as AmberPendingOperation; const parsed = JSON.parse(stored) as AmberPendingOperation;
if (isExpiredPending(parsed)) {
clearAmberPendingOperation();
return null;
}
return parsed;
} catch { } catch {
return null; return null;
} }
@ -117,7 +128,13 @@ export async function getAmberPendingOperationAsync(): Promise<AmberPendingOpera
if (getReq.result) { if (getReq.result) {
console.log('[Amber] Found pending op in IndexedDB'); console.log('[Amber] Found pending op in IndexedDB');
try { try {
resolve(JSON.parse(getReq.result)); const parsed = JSON.parse(getReq.result) as AmberPendingOperation;
if (isExpiredPending(parsed)) {
clearAmberPendingOperation();
resolve(null);
} else {
resolve(parsed);
}
} catch { } catch {
resolve(null); resolve(null);
} }
@ -529,11 +546,12 @@ export function openAmberForNip04Encrypt(pubkey: string, plaintext: string): voi
const operationId = generateOperationId(); const operationId = generateOperationId();
const callbackUrl = buildCallbackUrl(); const callbackUrl = buildCallbackUrl();
// Never persist the plaintext in a redirect pending-op record. The result
// comes back from Amber; the payload only needs to travel in the intent URL.
setAmberPendingOperation({ setAmberPendingOperation({
type: 'nip04_encrypt', type: 'nip04_encrypt',
id: operationId, id: operationId,
targetPubkey: pubkey, targetPubkey: pubkey,
plaintext,
timestamp: Date.now(), timestamp: Date.now(),
}); });
@ -581,11 +599,11 @@ export function openAmberForNip44Encrypt(pubkey: string, plaintext: string): voi
const operationId = generateOperationId(); const operationId = generateOperationId();
const callbackUrl = buildCallbackUrl(); const callbackUrl = buildCallbackUrl();
// Never persist plaintext in a redirect pending-op record.
setAmberPendingOperation({ setAmberPendingOperation({
type: 'nip44_encrypt', type: 'nip44_encrypt',
id: operationId, id: operationId,
targetPubkey: pubkey, targetPubkey: pubkey,
plaintext,
timestamp: Date.now(), timestamp: Date.now(),
}); });

View file

@ -123,7 +123,7 @@ export default function AmberCallbackPage() {
// ── encrypt / decrypt ─────────────────────────────────────────────── // ── encrypt / decrypt ───────────────────────────────────────────────
if (pendingOp.type.includes('encrypt') || pendingOp.type.includes('decrypt')) { if (pendingOp.type.includes('encrypt') || pendingOp.type.includes('decrypt')) {
localStorage.setItem('amber_crypto_result', result); // Do not persist crypto results (decrypt output can be plaintext).
clearAmberPendingOperation(); clearAmberPendingOperation();
setStatus('success'); setStatus('success');
setMessage('Operation complete! Returning...'); setMessage('Operation complete! Returning...');

View file

@ -12,7 +12,7 @@ import { NewMessageDialog } from '@/components/NewMessageDialog';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrLogin } from '@nostrify/react/login'; import { useNostrLogin } from '@nostrify/react/login';
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities'; import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities';
import { useConversations, useGroupConversations, type Conversation } from '@/hooks/useDirectMessages'; import { useConversations, useGroupConversations, type Conversation } from '@/hooks/useDirectMessages';
import { useUserCrews } from '@/hooks/useCrews'; import { useUserCrews } from '@/hooks/useCrews';
import { useCordnGroupGids } from '@/hooks/useCordnNative'; import { useCordnGroupGids } from '@/hooks/useCordnNative';
@ -277,10 +277,13 @@ export default function DirectMessagesPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { logins } = useNostrLogin(); const { logins } = useNostrLogin();
const { data: inbox, isLoading, error, refetch, isRefetching } = useConversations(); const interactive = isInteractiveSigner(user, logins);
const [amberInboxUnlocked, setAmberInboxUnlocked] = useState(false);
const inboxEnabled = !interactive || amberInboxUnlocked;
const { data: inbox, isLoading, error, refetch, isRefetching } = useConversations({ enabled: inboxEnabled });
const conversations = inbox?.conversations; const conversations = inbox?.conversations;
const undecryptable = inbox?.undecryptable ?? 0; const undecryptable = inbox?.undecryptable ?? 0;
const { data: groups, refetch: refetchGroups } = useGroupConversations(); const { data: groups, refetch: refetchGroups } = useGroupConversations({ enabled: inboxEnabled });
const { getLastRead } = useDMRead(); const { getLastRead } = useDMRead();
const { markSeen: markDmEnvelopesSeen } = useDmUnreadSummary(!!user); const { markSeen: markDmEnvelopesSeen } = useDmUnreadSummary(!!user);
const { isMuted } = useDmMute(); const { isMuted } = useDmMute();
@ -412,7 +415,7 @@ export default function DirectMessagesPage() {
void refetch(); void refetch();
void refetchGroups(); void refetchGroups();
}} }}
disabled={isRefetching} disabled={isRefetching || !inboxEnabled}
aria-label={t('socialUi.dms.refresh')} aria-label={t('socialUi.dms.refresh')}
className="h-10 px-3 sm:h-auto sm:px-4" className="h-10 px-3 sm:h-auto sm:px-4"
> >
@ -430,6 +433,28 @@ export default function DirectMessagesPage() {
} }
/> />
{interactive && !amberInboxUnlocked && (
<Card className="mb-5 border-teal-500/25 bg-teal-500/[0.04]">
<CardContent className="flex items-start gap-3 p-4">
<Lock className="mt-0.5 h-5 w-5 shrink-0 text-teal-400" />
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-white">{t('socialUi.dms.amberUnlockTitle')}</p>
<p className="mt-1 text-xs leading-relaxed text-gray-400">
{t('socialUi.dms.amberUnlockBody')}
</p>
<Button
size="sm"
onClick={() => setAmberInboxUnlocked(true)}
className="mt-3 gap-1.5 bg-teal-500 font-bold text-black hover:bg-teal-400"
>
<Lock className="h-3.5 w-3.5" />
{t('socialUi.dms.amberUnlockCta')}
</Button>
</div>
</CardContent>
</Card>
)}
{/* Cordn private rooms — community + each crew's MLS chat */} {/* Cordn private rooms — community + each crew's MLS chat */}
<div className="mb-5"> <div className="mb-5">
<h2 className="mb-2 flex items-center gap-1.5 text-xs font-bold uppercase tracking-widest text-gray-500"> <h2 className="mb-2 flex items-center gap-1.5 text-xs font-bold uppercase tracking-widest text-gray-500">
@ -465,6 +490,8 @@ export default function DirectMessagesPage() {
</div> </div>
</div> </div>
{inboxEnabled && (
<>
{/* Group conversations (NIP-17 group DMs) */} {/* Group conversations (NIP-17 group DMs) */}
{groups && groups.length > 0 && ( {groups && groups.length > 0 && (
<div className="mb-5"> <div className="mb-5">
@ -536,6 +563,8 @@ export default function DirectMessagesPage() {
</p> </p>
)} )}
</div> </div>
</>
)}
</div> </div>
{/* New Message — searchable people picker */} {/* New Message — searchable people picker */}