fix: gate Amber inbox decryption and expire redirect ops
This commit is contained in:
parent
5acaa3b2b4
commit
53cad29c7e
8 changed files with 95 additions and 14 deletions
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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
25
src/lib/amber.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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...');
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue