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 'nip44_decrypt': {
// 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();
toast({
title: t('settingsUi.amberCallback.operationCompleteTitle'),

View file

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

View file

@ -4093,6 +4093,9 @@ socialUi: {
runBy: 'Coordinator run by',
},
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',
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.",

View file

@ -4093,6 +4093,9 @@ socialUi: {
runBy: 'Coordinador gestionado por',
},
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',
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.',

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_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.
@ -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.
*/
@ -81,7 +87,12 @@ export function getAmberPendingOperation(): AmberPendingOperation | null {
if (!stored) return null;
try {
return JSON.parse(stored) as AmberPendingOperation;
const parsed = JSON.parse(stored) as AmberPendingOperation;
if (isExpiredPending(parsed)) {
clearAmberPendingOperation();
return null;
}
return parsed;
} catch {
return null;
}
@ -117,7 +128,13 @@ export async function getAmberPendingOperationAsync(): Promise<AmberPendingOpera
if (getReq.result) {
console.log('[Amber] Found pending op in IndexedDB');
try {
resolve(JSON.parse(getReq.result));
const parsed = JSON.parse(getReq.result) as AmberPendingOperation;
if (isExpiredPending(parsed)) {
clearAmberPendingOperation();
resolve(null);
} else {
resolve(parsed);
}
} catch {
resolve(null);
}
@ -529,11 +546,12 @@ export function openAmberForNip04Encrypt(pubkey: string, plaintext: string): voi
const operationId = generateOperationId();
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({
type: 'nip04_encrypt',
id: operationId,
targetPubkey: pubkey,
plaintext,
timestamp: Date.now(),
});
@ -581,11 +599,11 @@ export function openAmberForNip44Encrypt(pubkey: string, plaintext: string): voi
const operationId = generateOperationId();
const callbackUrl = buildCallbackUrl();
// Never persist plaintext in a redirect pending-op record.
setAmberPendingOperation({
type: 'nip44_encrypt',
id: operationId,
targetPubkey: pubkey,
plaintext,
timestamp: Date.now(),
});

View file

@ -123,7 +123,7 @@ export default function AmberCallbackPage() {
// ── encrypt / 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();
setStatus('success');
setMessage('Operation complete! Returning...');

View file

@ -12,7 +12,7 @@ import { NewMessageDialog } from '@/components/NewMessageDialog';
import { useTranslation } from '@/contexts/language';
import { useCurrentUser } from '@/hooks/useCurrentUser';
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 { useUserCrews } from '@/hooks/useCrews';
import { useCordnGroupGids } from '@/hooks/useCordnNative';
@ -277,10 +277,13 @@ export default function DirectMessagesPage() {
const { t } = useTranslation();
const { user } = useCurrentUser();
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 undecryptable = inbox?.undecryptable ?? 0;
const { data: groups, refetch: refetchGroups } = useGroupConversations();
const { data: groups, refetch: refetchGroups } = useGroupConversations({ enabled: inboxEnabled });
const { getLastRead } = useDMRead();
const { markSeen: markDmEnvelopesSeen } = useDmUnreadSummary(!!user);
const { isMuted } = useDmMute();
@ -412,7 +415,7 @@ export default function DirectMessagesPage() {
void refetch();
void refetchGroups();
}}
disabled={isRefetching}
disabled={isRefetching || !inboxEnabled}
aria-label={t('socialUi.dms.refresh')}
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 */}
<div className="mb-5">
<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>
{inboxEnabled && (
<>
{/* Group conversations (NIP-17 group DMs) */}
{groups && groups.length > 0 && (
<div className="mb-5">
@ -536,6 +563,8 @@ export default function DirectMessagesPage() {
</p>
)}
</div>
</>
)}
</div>
{/* New Message — searchable people picker */}