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 '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'),
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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
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_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(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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...');
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue