diff --git a/docs/THREAT-MODEL.md b/docs/THREAT-MODEL.md index bf9dc60..dc4ccef 100644 --- a/docs/THREAT-MODEL.md +++ b/docs/THREAT-MODEL.md @@ -103,6 +103,8 @@ Residuals (see `docs/FORWARD-SECRECY.md`): no Double Ratchet / post-compromise h ## Fixed +- **Deep links could pre-arm the verify screen's confirmation button (found 21 Jul 2026, security audit).** `app.json` registers the `protestchat://` scheme, and expo-router auto-linking meant any website, QR code, or other app could push `protestchat://verify/` straight onto the navigation stack with a live "They match, mark verified" button, no in-person comparison required. A link cannot forge a matching safety number, but it delivered a pre-armed verification UI outside the in-person ritual, a social-engineering assist against A5 aimed at exactly the stressed, non-expert users this app is for. Fixed: the confirm/deny buttons are now gated on an in-memory, single-use flag (`src/lib/verify-session.ts`) that only the in-app "Verify" press in chat can set. A screen reached any other way still shows the correct safety-number digits, since they are just as reachable from the peer's public id either way, but offers no confirmation action. + - **Decrypted plaintext was never aged out (found 20 Jul 2026).** The 6-hour TTL was documented as the post-seizure mitigation (A6), but `sweepExpired()` only touched the envelope carry cache and the dedup ledger. The `messages` table — cleartext bodies, sender ids, conversation history — was retained indefinitely on disk until a panic wipe or leaving the channel/group. A seized unlocked phone therefore exposed the entire history the app had ever displayed, defeating the advertised retention window. Fixed: `messages` and their `message_recipients` ledger rows are now swept on the same timer and the same 6-hour clock, measured from `first_seen` (local insert time, not the sender's minute-rounded `sent_at`). - **Message replay (found 20 Jul 2026, security audit).** Dedup was only on the random outer envelope id, which a replay attacker simply regenerates — so a captured ciphertext, re-wrapped in a fresh envelope id, was re-decrypted and filed as a new message. A months-old "we move at nine" could be resurrected at will. Now each device also dedups on a hash of the *decrypted* body (`seen_messages` in `db.ts`), so an exact replay is dropped while genuine resends (fresh per-message id) are unaffected. diff --git a/src/app/chat/[id].tsx b/src/app/chat/[id].tsx index 0326e20..8381e66 100644 --- a/src/app/chat/[id].tsx +++ b/src/app/chat/[id].tsx @@ -31,6 +31,7 @@ import { useI18n } from '@/i18n/provider'; import { useApp } from '@/lib/app-state'; import { describeConversation, type ConversationInfo } from '@/lib/conversation'; import * as db from '@/lib/db'; +import { armVerification } from '@/lib/verify-session'; export default function ChatScreen() { const t = useTheme(); @@ -153,7 +154,10 @@ export default function ChatScreen() { // one warning a user can actually act on from here. onPress={ info.mode === 'direct' && !contact?.verified - ? () => router.push(`/verify/${encodeURIComponent(conversationId)}`) + ? () => { + armVerification(conversationId); + router.push(`/verify/${encodeURIComponent(conversationId)}`); + } : undefined } /> diff --git a/src/app/verify/[id].tsx b/src/app/verify/[id].tsx index 6ec9c74..191a41c 100644 --- a/src/app/verify/[id].tsx +++ b/src/app/verify/[id].tsx @@ -15,6 +15,7 @@ */ import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; +import { useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { Button, Notice, Screen, Tag } from '@/components/ui'; @@ -22,6 +23,7 @@ import { Fonts, Radius, Spacing, Type } from '@/constants/theme'; import { useTheme } from '@/hooks/use-theme'; import { useI18n } from '@/i18n/provider'; import { useApp } from '@/lib/app-state'; +import { consumeVerificationArm } from '@/lib/verify-session'; export default function VerifyScreen() { const t = useTheme(); @@ -35,6 +37,12 @@ export default function VerifyScreen() { const digits = safetyNumberFor(peerId); const rows = digits ? digits.split(' ') : []; + // Consumed once, on first mount of this exact screen instance, so it can + // only ever reflect the in-app "Verify" press that armed this id — never a + // deep link, which mounts the same screen with the same params but never + // calls armVerification first. See src/lib/verify-session.ts. + const [openedInApp] = useState(() => consumeVerificationArm(peerId)); + return ( @@ -83,7 +91,7 @@ export default function VerifyScreen() { onPress={() => void verifyContact(peerId, false)} /> - ) : ( + ) : openedInApp ? (