Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/THREAT-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<publicId>` 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.
Expand Down
6 changes: 5 additions & 1 deletion src/app/chat/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
}
/>
Expand Down
19 changes: 18 additions & 1 deletion src/app/verify/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@
*/

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';
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();
Expand All @@ -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 (
<Screen contentStyle={{ gap: Spacing.xl }}>
<Stack.Screen options={{ title: contact?.name ?? copy('verify.title') }} />
Expand Down Expand Up @@ -83,7 +91,7 @@ export default function VerifyScreen() {
onPress={() => void verifyContact(peerId, false)}
/>
</View>
) : (
) : openedInApp ? (
<View style={{ gap: Spacing.md }}>
<Button
title={copy('verify.match')}
Expand All @@ -97,6 +105,15 @@ export default function VerifyScreen() {
calm-looking option is the dangerous one. */}
<Button title={copy('verify.noMatch')} variant="danger" onPress={() => router.back()} />
</View>
) : (
// Reached by a deep link, QR code, or another app — not by the in-app
// "Verify" button in chat. A link can carry a real id and this screen
// will still show the correct digits for it, but it cannot carry the
// in-person comparison that verification exists to prove happened.
// No confirmation action is offered here on purpose (issue #34).
<Notice tone="caution">
<Text style={[Type.callout, { color: t.text }]}>{copy('verify.externalOrigin')}</Text>
</Notice>
)}
</Screen>
);
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/bn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ export const bn: Catalog = {
'verify.inPerson': 'সামনাসামনি এটি করুন। ফোন কলে পড়া বা অন্য অ্যাপে পাঠানো নম্বর সেই কল বা অ্যাপ নিয়ন্ত্রণকারী ব্যক্তি নকল করতে পারে।',
'verify.checked': 'আপনি এই ব্যক্তিকে যাচাই করা হিসেবে চিহ্নিত করেছেন।',
'verify.match': 'নম্বর মেলে — যাচাই করুন', 'verify.noMatch': 'নম্বর মেলে না',
'verify.externalOrigin':
'এই স্ক্রিনটি অ্যাপের বাইরে থেকে খোলা হয়েছে, তাই এখানে যাচাই নিশ্চিত করা যাবে না। এই ব্যক্তির চ্যাট থেকে এটি খুলুন এবং সামনাসামনি নিরাপত্তা নম্বর যাচাই করতে যাচাই করুন-এ ট্যাপ করুন।',
'radio.preparingTitle': 'নিরাপদভাবে প্রস্তুত হচ্ছে', 'radio.preparingDetail': 'আপনার পরিচয় ও স্থানীয় বার্তা লোড হচ্ছে।',
'radio.permissionTitle': 'Bluetooth অনুমতি প্রয়োজন',
'radio.permissionDetail': 'Bluetooth ছাড়া কাছাকাছি বার্তা আদানপ্রদান কাজ করবে না। সেটিংসে অনুমতি দিয়ে ফিরে আসুন।',
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,8 @@ export const en = {
'verify.checked': 'You marked this person as checked.',
'verify.match': 'They match — mark verified',
'verify.noMatch': 'They do not match',
'verify.externalOrigin':
"This screen was opened from outside the app, so it cannot confirm verification here. Open it from this person's chat and tap Verify to check safety numbers in person.",

'radio.preparingTitle': 'Preparing securely',
'radio.preparingDetail': 'Loading your identity and local messages.',
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/hi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ export const hi: Catalog = {
'verify.checked': 'आपने इस व्यक्ति को जाँचा हुआ चिह्नित किया है।',
'verify.match': 'नंबर मिलते हैं — सत्यापित करें',
'verify.noMatch': 'नंबर नहीं मिलते',
'verify.externalOrigin':
'यह स्क्रीन ऐप के बाहर से खोली गई है, इसलिए यहाँ सत्यापन की पुष्टि नहीं हो सकती। इस व्यक्ति की चैट से इसे खोलें और आमने-सामने सुरक्षा नंबर जाँचने के लिए सत्यापित करें पर टैप करें।',
'radio.preparingTitle': 'सुरक्षित रूप से तैयार हो रहा है',
'radio.preparingDetail': 'आपकी पहचान और स्थानीय संदेश लोड हो रहे हैं।',
'radio.permissionTitle': 'Bluetooth अनुमति आवश्यक है',
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/mr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ export const mr: Catalog = {
'verify.inPerson': 'हे प्रत्यक्ष करा. फोन कॉलवर वाचलेले किंवा दुसऱ्या ॲपमध्ये पाठवलेले क्रमांक तो कॉल किंवा ॲप नियंत्रित करणारी व्यक्ती बनावट करू शकते.',
'verify.checked': 'तुम्ही या व्यक्तीला तपासलेले म्हणून चिन्हांकित केले आहे.',
'verify.match': 'क्रमांक जुळतात — पडताळा', 'verify.noMatch': 'क्रमांक जुळत नाहीत',
'verify.externalOrigin':
'ही स्क्रीन अॅपच्या बाहेरून उघडली गेली आहे, त्यामुळे येथे पडताळणी निश्चित करता येणार नाही. या व्यक्तीच्या चॅटमधून ती उघडा आणि समोरासमोर सुरक्षा क्रमांक तपासण्यासाठी पडताळा वर टॅप करा.',
'radio.preparingTitle': 'सुरक्षितपणे तयार होत आहे', 'radio.preparingDetail': 'तुमची ओळख आणि स्थानिक संदेश लोड होत आहेत.',
'radio.permissionTitle': 'Bluetooth परवानगी आवश्यक',
'radio.permissionDetail': 'Bluetooth प्रवेशाशिवाय जवळचे संदेश चालणार नाहीत. सेटिंग्जमध्ये परवानगी द्या आणि परत या.',
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/ta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ export const ta: Catalog = {
'verify.inPerson': 'இதை நேரில் செய்யுங்கள். தொலைபேசி அழைப்பில் வாசித்த அல்லது வேறு செயலியில் அனுப்பிய எண்களை அந்த அழைப்பு அல்லது செயலியைக் கட்டுப்படுத்துபவர் போலியாக்கலாம்.',
'verify.checked': 'இந்த நபரைச் சரிபார்த்ததாகக் குறித்துள்ளீர்கள்.',
'verify.match': 'எண்கள் பொருந்துகின்றன — சரிபார்', 'verify.noMatch': 'எண்கள் பொருந்தவில்லை',
'verify.externalOrigin':
'இந்த திரை பயன்பாட்டிற்கு வெளியே இருந்து திறக்கப்பட்டது, எனவே இங்கே சரிபார்ப்பை உறுதிப்படுத்த முடியாது. இந்த நபரின் அரட்டையிலிருந்து இதைத் திறந்து, நேருக்கு நேர் பாதுகாப்பு எண்களை சரிபார்க்க சரிபார் என்பதைத் தட்டவும்.',
'radio.preparingTitle': 'பாதுகாப்பாகத் தயாராகிறது', 'radio.preparingDetail': 'உங்கள் அடையாளமும் உள்ளூர் செய்திகளும் ஏற்றப்படுகின்றன.',
'radio.permissionTitle': 'Bluetooth அனுமதி தேவை',
'radio.permissionDetail': 'Bluetooth அணுகல் இல்லாமல் அருகிலுள்ள செய்தி பரிமாற்றம் இயங்காது. அமைப்புகளில் அனுமதித்து திரும்பவும்.',
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/te.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ export const te: Catalog = {
'verify.inPerson': 'దీన్ని ఎదురెదురుగా చేయండి. ఫోన్ కాల్‌లో చదివిన లేదా మరో యాప్‌లో పంపిన నంబర్లను ఆ కాల్ లేదా యాప్‌ను నియంత్రించే వ్యక్తి నకిలీ చేయవచ్చు.',
'verify.checked': 'మీరు ఈ వ్యక్తిని తనిఖీ చేసినట్లుగా గుర్తించారు.',
'verify.match': 'నంబర్లు సరిపోలాయి — ధృవీకరించండి', 'verify.noMatch': 'నంబర్లు సరిపోలలేదు',
'verify.externalOrigin':
'ఈ స్క్రీన్ యాప్ వెలుపల నుండి తెరవబడింది, కాబట్టి ఇక్కడ ధృవీకరణను నిర్ధారించలేము. ఈ వ్యక్తి యొక్క చాట్ నుండి దీన్ని తెరిచి, ముఖాముఖిగా భద్రతా నంబర్లను తనిఖీ చేయడానికి ధృవీకరించండి పై నొక్కండి.',
'radio.preparingTitle': 'సురక్షితంగా సిద్ధమవుతోంది', 'radio.preparingDetail': 'మీ గుర్తింపు, స్థానిక సందేశాలు లోడ్ అవుతున్నాయి.',
'radio.permissionTitle': 'Bluetooth అనుమతి అవసరం',
'radio.permissionDetail': 'Bluetooth యాక్సెస్ లేకుండా సమీప సందేశాలు పనిచేయవు. సెట్టింగ్‌లలో అనుమతించి తిరిగి రండి.',
Expand Down
40 changes: 40 additions & 0 deletions src/lib/__tests__/verify-session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Deep links must never land directly on an armed verification confirmation
* (issue #34) — only a prior in-app arm for that exact id, within the
* window, consumes true.
*/

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';

import { armVerification, consumeVerificationArm } from '../verify-session';

describe('verify session arming', () => {
it('is unarmed for an id that was never armed, as with a cold deep link', () => {
assert.equal(consumeVerificationArm('never-armed'), false);
});

it('arms only the exact id the in-app button pressed', () => {
armVerification('alice');
assert.equal(consumeVerificationArm('bob'), false);
assert.equal(consumeVerificationArm('alice'), true);
});

it('is single-use — a second mount for the same id is unarmed', () => {
armVerification('alice');
assert.equal(consumeVerificationArm('alice'), true);
assert.equal(consumeVerificationArm('alice'), false);
});

it('expires after the arm window', () => {
const armedAt = 1_000_000;
armVerification('alice', armedAt);
assert.equal(consumeVerificationArm('alice', armedAt + 10 * 60 * 1000), false);
});

it('is still armed just inside the window', () => {
const armedAt = 1_000_000;
armVerification('alice', armedAt);
assert.equal(consumeVerificationArm('alice', armedAt + 4 * 60 * 1000), true);
});
});
28 changes: 28 additions & 0 deletions src/lib/verify-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Proof that the verify screen for a given contact was opened from the
* in-app "Verify" action in chat, not from an incoming deep link, QR-driven
* intent, or another app.
*
* A URL cannot carry this proof: whatever a link puts in `/verify/<id>` is
* exactly the same param the in-app button would have produced, so origin
* has to be something a link literally cannot set. This is an in-memory,
* single-use flag armed by the trusted button press and consumed the moment
* the screen mounts. It is never persisted and never derived from anything
* external, so a `protestchat://verify/<id>` link always arrives unarmed.
*/

const armed = new Map<string, number>();

// Generous enough to survive the push/mount tick without ever making sense
// as a window an attacker could race into from outside the app.
const ARM_WINDOW_MS = 5 * 60 * 1000;

export function armVerification(publicId: string, now = Date.now()): void {
armed.set(publicId, now);
}

export function consumeVerificationArm(publicId: string, now = Date.now()): boolean {
const at = armed.get(publicId);
armed.delete(publicId);
return at !== undefined && now - at <= ARM_WINDOW_MS;
}