3 Commits

Author SHA1 Message Date
vsecoder
af7223b93c feat(client): device pairing flow (v2.2.0-alpha3)
Completes PR #3 of the multi-device roadmap. Two devices of the same
identity can now be linked via a six-digit code + relay envelope
handshake. Chain-level fan-out (alpha2) and self-wipe on revoke (this
release, earlier commit) already work end-to-end.

New device — app/(auth)/pair.tsx:
  * Generates fresh X25519 keypair + six-digit code locally.
  * Displays them for transcription on the primary device.
  * Polls /relay/inbox on its own x25519 pub every 2.5s.
  * Decrypts each envelope with the session priv + sender_pub.
  * Accepts only payloads where {v=1, type=pair-handshake, code matches}.
  * On success, assembles a KeyFile (master Ed25519 from envelope,
    x25519 from session) and redirects into (app).

Primary device — app/(app)/devices.tsx:
  * "Link new device" opens a modal asking for {code, device key, name}.
  * On submit: builds+submits LINK_DEVICE tx for the new pub, then
    sends a one-shot relay envelope carrying {master_pub, master_priv,
    master_x25519_pub, code} encrypted for the new device's x25519 pub.
  * Optimistic local insert so the new row appears immediately.

Entry point — app/index.tsx:
  * Third button on welcome slide 3: "Pair". Routes to /auth/pair.
    Create / Import remain unchanged for fresh identities.

Security:
  * Master Ed25519 priv leaves the source device ONLY inside an envelope
    sealed with NaCl box for the new device's X25519 pub. The relay node
    sees only ciphertext.
  * Six-digit code (~20 bits) gates acceptance — an attacker who guesses
    both a session pub AND the code is still filtered by the X25519
    decryption itself (code match is belt-and-suspenders).
  * Envelope stays in relay mailbox until TTL — no DELETE call yet;
    idempotent on our side (saveKeyFile overwrites, session pub
    never polled after redirect).

Known trade-offs:
  * Manual transcription of a 64-char hex key is ugly. Alpha4 will
    offer a QR fallback on phones with cameras; desktop keeps typing.
  * No rate limit on the polling. Fine for a 1-minute handshake, needs
    cap-on-stale if a user leaves the screen open.
2026-04-22 16:32:34 +03:00
vsecoder
8940b97cc6 feat(client): Devices screen + revoke self-wipe (v2.2.0-alpha3 wip)
Part of PR #3. Pairing flow still to come.

Devices screen — app/(app)/devices.tsx:
  * Lists every active device from /api/devices/{self}.
  * "THIS DEVICE" badge on our own row, Unlink button on every other.
  * Unlink confirms + submits UNLINK_DEVICE tx, optimistic local removal.
  * Pull-to-refresh; empty state when balance is too low for auto-link.
  * Placeholder row for "Link new device" — wired in next commit.

Settings → Devices entry row: added under a new "Devices" section.

Self-wipe on revoke — lib/storage.ts + app/(app)/_layout.tsx:
  * New AsyncStorage marker `dchain_device_registered` tracks whether
    this install ever made it into the on-chain registry.
  * wipeAllLocalState() zeroes secure-store key + contacts + settings +
    chats cache + marker. Safe-idempotent.
  * Bootstrap effect in app layout splits three branches by
    (our_pub in chain's active list × marker_set):
      - in list      → mark registered, done.
      - not in list + was registered → REVOKED → wipe + redirect to auth.
      - not in list + never registered → first boot, LINK_DEVICE.
  * Network errors never trigger wipe — only an explicit "pub missing
    from chain response" decides it. Belt-and-suspenders against a
    misbehaving node spuriously dropping records.

Next: pairing flow so a second device (desktop, tablet, new phone)
can come online, show a 6-digit code, receive master priv via a
one-shot relay envelope encrypted to its fresh device X25519 pub,
then self-link.
2026-04-22 16:28:16 +03:00
vsecoder
423d307125 feat(client): multi-device fan-out + auto-link (v2.2.0-alpha2)
PR #2 of the multi-device roadmap — wires the messenger pipeline
against the on-chain registry landed in v2.2.0-alpha1.

lib/api.ts:
  - DeviceInfo type mirroring blockchain.DeviceInfo.
  - IdentityInfo.device_count (optional; populated from /api/identity).
  - fetchDevices(masterPub) → /api/devices/{master_pub}, returns [].
    Errors swallowed so a downed endpoint doesn't block messaging.
  - resolveRecipientKeys(masterPub) — the routing primitive. Returns
    devices[] if registered, else falls back to IdentityInfo.x25519_pub
    (pre-v2.2.0 path). Empty only when recipient has published nothing.
  - buildLinkDeviceTx / buildUnlinkDeviceTx — signed by master Ed25519,
    min-fee cost, canonical JSON payload matching the chain-side
    LinkDevicePayload / UnlinkDevicePayload.

app/(app)/chats/[id].tsx:
  - sendCore now fans out: encrypts once per recipient device pub
    (Promise.all, any failure rejects the batch), falls back to the
    cached contact.x25519Pub if the registry lookup returns nothing.
  - Saved Messages short-circuit preserved; no devices lookup for self.

app/(app)/_layout.tsx:
  - On every sign-in, auto-submit LINK_DEVICE for this device if its
    X25519 pub isn't already in the master's registry. Device name
    picks "iPhone" / "Android phone" / "Device" by Platform. Errors
    (insufficient balance / legacy chain without LINK_DEVICE support)
    are silent — next launch retries.

Backward compatibility: senders fall back to identity.x25519_pub when
the recipient has no registry entries, so pre-v2.2.0 clients still
receive messages. Chain-side already gates new validation on the event
types existing; old clients simply never emit LINK_DEVICE and keep
working with a single X25519.

Next — PR #3 (Settings → Devices screen + QR pairing flow + receive-side
self-wipe on revoke detection).
2026-04-22 16:24:36 +03:00
8 changed files with 1109 additions and 13 deletions

View File

@@ -13,7 +13,7 @@
* один раз; переходы между tab'ами их не перезапускают. * один раз; переходы между tab'ами их не перезапускают.
*/ */
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { View } from 'react-native'; import { View, Platform } from 'react-native';
import { router, usePathname } from 'expo-router'; import { router, usePathname } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store'; import { useStore } from '@/lib/store';
@@ -25,7 +25,13 @@ import { useGlobalInbox } from '@/hooks/useGlobalInbox';
import { getWSClient } from '@/lib/ws'; import { getWSClient } from '@/lib/ws';
import { NavBar } from '@/components/NavBar'; import { NavBar } from '@/components/NavBar';
import { AnimatedSlot } from '@/components/AnimatedSlot'; import { AnimatedSlot } from '@/components/AnimatedSlot';
import { saveContact } from '@/lib/storage'; import {
saveContact,
isDeviceRegistered, markDeviceRegistered, wipeAllLocalState,
} from '@/lib/storage';
import {
fetchDevices, buildLinkDeviceTx, submitTx,
} from '@/lib/api';
export default function AppLayout() { export default function AppLayout() {
const keyFile = useStore(s => s.keyFile); const keyFile = useStore(s => s.keyFile);
@@ -73,6 +79,79 @@ export default function AppLayout() {
else ws.setAuthCreds(null); else ws.setAuthCreds(null);
}, [keyFile]); }, [keyFile]);
// Multi-device registry bootstrap + revoke-detection (v2.2.0).
//
// Three branches, by (chain list × local "was registered" flag):
//
// 1. Our pub is in the chain's active list →
// mark us registered locally (idempotent), done.
//
// 2. Our pub is NOT in the active list, AND we've registered before →
// another device issued UNLINK_DEVICE against us. Wipe ALL local
// state (master priv, contacts, chats, marker) and redirect to
// the auth screen. This is the security-critical path: without
// the wipe, a stolen phone after revoke would still decrypt
// historical messages.
//
// 3. Our pub is NOT in the active list, AND we've NEVER registered →
// first boot on this chain; submit LINK_DEVICE so senders can
// target us. Failures (fee, offline) are swallowed; next launch
// retries.
useEffect(() => {
if (!keyFile) return;
let cancelled = false;
(async () => {
let chainList;
try {
chainList = await fetchDevices(keyFile.pub_key);
} catch {
// Network unavailable — leave state unchanged; we'll resync on
// the next launch. Do NOT wipe on network error.
return;
}
if (cancelled) return;
const inActive = chainList.some(d => d.x25519_pub_key === keyFile.x25519_pub);
const previouslyRegistered = await isDeviceRegistered();
if (cancelled) return;
if (inActive) {
// Branch #1 — ensure the local marker is set.
if (!previouslyRegistered) await markDeviceRegistered();
return;
}
if (previouslyRegistered) {
// Branch #2 — REVOKED. Self-wipe.
await wipeAllLocalState();
useStore.getState().setKeyFile(null);
// The redirect-on-null-keyFile effect below will push the user
// back to the welcome screen automatically.
return;
}
// Branch #3 — first-boot link. Best-effort.
try {
const deviceName = Platform.select({
ios: 'iPhone',
android: 'Android phone',
default: 'Device',
}) ?? 'Device';
const tx = buildLinkDeviceTx({
from: keyFile.pub_key,
x25519Pub: keyFile.x25519_pub,
deviceName,
privKey: keyFile.priv_key,
});
await submitTx(tx);
await markDeviceRegistered();
} catch {
/* next launch retries */
}
})();
return () => { cancelled = true; };
}, [keyFile]);
useEffect(() => { useEffect(() => {
if (keyFile === null) { if (keyFile === null) {
const t = setTimeout(() => { const t = setTimeout(() => {

View File

@@ -23,7 +23,7 @@ import * as Clipboard from 'expo-clipboard';
import { useStore } from '@/lib/store'; import { useStore } from '@/lib/store';
import { useMessages } from '@/hooks/useMessages'; import { useMessages } from '@/hooks/useMessages';
import { encryptMessage } from '@/lib/crypto'; import { encryptMessage } from '@/lib/crypto';
import { sendEnvelope } from '@/lib/api'; import { sendEnvelope, resolveRecipientKeys } from '@/lib/api';
import { getWSClient } from '@/lib/ws'; import { getWSClient } from '@/lib/ws';
import { appendMessage, loadMessages } from '@/lib/storage'; import { appendMessage, loadMessages } from '@/lib/storage';
import { randomId, safeBack } from '@/lib/utils'; import { randomId, safeBack } from '@/lib/utils';
@@ -212,15 +212,34 @@ export default function ChatScreen() {
// leaves the device, so no encryption/fee/network round-trip is needed. // leaves the device, so no encryption/fee/network round-trip is needed.
// Regular chats still go through the NaCl + relay pipeline below. // Regular chats still go through the NaCl + relay pipeline below.
if (hasText && !isSavedMessages) { if (hasText && !isSavedMessages) {
// Multi-device fan-out (v2.2.0): resolve the recipient's active
// device X25519 pubs via /api/devices. Legacy identities (no
// devices registered) fall back to their published identity
// x25519 pub, preserving the pre-v2.2.0 single-device path.
// `contact.x25519Pub` stays the floor — if both network calls
// fail we still attempt delivery to the cached pub so a flaky
// connection doesn't block outgoing messages.
let recipientPubs = await resolveRecipientKeys(contact.address);
if (recipientPubs.length === 0 && contact.x25519Pub) {
recipientPubs = [contact.x25519Pub];
}
if (recipientPubs.length === 0) {
throw new Error('recipient has no encryption key published');
}
// One sealed envelope per recipient device. Parallel — slow
// relays don't block each other; any individual failure
// rejects the whole send (user retries).
await Promise.all(recipientPubs.map(async (rpub) => {
const { nonce, ciphertext } = encryptMessage( const { nonce, ciphertext } = encryptMessage(
actualText.trim(), keyFile.x25519_priv, contact.x25519Pub, actualText.trim(), keyFile.x25519_priv, rpub,
); );
await sendEnvelope({ await sendEnvelope({
senderPub: keyFile.x25519_pub, senderPub: keyFile.x25519_pub,
recipientPub: contact.x25519Pub, recipientPub: rpub,
senderEd25519Pub: keyFile.pub_key, senderEd25519Pub: keyFile.pub_key,
nonce, ciphertext, nonce, ciphertext,
}); });
}));
} }
const msg: Message = { const msg: Message = {

View File

@@ -0,0 +1,490 @@
/**
* Devices screen — Settings → Linked devices.
*
* Multi-device registry (v2.2.0). Lists every X25519 device published
* on-chain under this identity's master Ed25519 key. Operators can:
* - see added-at timestamps
* - rename this device (local alias for now; rename via LINK_DEVICE
* with same pub + new name is a v2.3 polish)
* - revoke a remote device via UNLINK_DEVICE (requires fee)
* - pair a new device (Phase 3 — separate modal, stub for now)
*
* This device is NEVER listed with an Unlink button — revoking yourself
* is a footgun (you'd wipe your own state on next launch). Export/import
* your key first, then revoke from the new device.
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
View, Text, ScrollView, Pressable, ActivityIndicator, Alert, RefreshControl,
TextInput, KeyboardAvoidingView, Platform, Modal,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import {
fetchDevices, buildLinkDeviceTx, buildUnlinkDeviceTx, submitTx,
sendEnvelope, humanizeTxError,
type DeviceInfo,
} from '@/lib/api';
import { encryptMessage } from '@/lib/crypto';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
import { safeBack } from '@/lib/utils';
function shortPub(p: string, n = 8): string {
if (!p) return '—';
return p.length <= n * 2 + 1 ? p : `${p.slice(0, n)}${p.slice(-n)}`;
}
function formatDate(unixSec: number): string {
return new Date(unixSec * 1000).toLocaleString();
}
export default function DevicesScreen() {
const insets = useSafeAreaInsets();
const keyFile = useStore(s => s.keyFile);
const [devices, setDevices] = useState<DeviceInfo[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [unlinking, setUnlinking] = useState<string | null>(null); // pub being revoked
const load = useCallback(async (isRefresh = false) => {
if (!keyFile) return;
if (isRefresh) setRefreshing(true);
else setLoading(true);
try {
const list = await fetchDevices(keyFile.pub_key);
setDevices(list);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [keyFile]);
useEffect(() => { load(false); }, [load]);
const onUnlink = useCallback((dev: DeviceInfo) => {
if (!keyFile) return;
Alert.alert(
'Unlink device?',
`"${dev.device_name}" will stop receiving messages sent to you. ` +
`This costs a small network fee. The revoked device wipes its ` +
`local state the next time it checks in.`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Unlink',
style: 'destructive',
onPress: async () => {
setUnlinking(dev.x25519_pub_key);
try {
const tx = buildUnlinkDeviceTx({
from: keyFile.pub_key,
x25519Pub: dev.x25519_pub_key,
privKey: keyFile.priv_key,
});
await submitTx(tx);
// Optimistic — drop from local list immediately; next load
// reconciles. Chain tx takes ~1 block to commit.
setDevices(prev => prev.filter(d => d.x25519_pub_key !== dev.x25519_pub_key));
} catch (e: any) {
Alert.alert('Unlink failed', humanizeTxError(e));
} finally {
setUnlinking(null);
}
},
},
],
);
}, [keyFile]);
const meX25519 = keyFile?.x25519_pub ?? '';
// Pairing modal state — filled by the operator reading values off the
// new device's /auth/pair screen.
const [pairOpen, setPairOpen] = useState(false);
const [pairCode, setPairCode] = useState('');
const [pairKey, setPairKey] = useState('');
const [pairName, setPairName] = useState('');
const [pairBusy, setPairBusy] = useState(false);
const submitPair = useCallback(async () => {
if (!keyFile) return;
const code = pairCode.replace(/\s+/g, '').trim();
const key = pairKey.replace(/\s+/g, '').trim().toLowerCase();
if (!/^\d{6}$/.test(code)) {
Alert.alert('Invalid code', 'The pairing code is 6 digits.');
return;
}
if (!/^[0-9a-f]{64}$/.test(key)) {
Alert.alert('Invalid key', 'The device key must be 64 hex characters.');
return;
}
const name = pairName.trim() || 'New device';
setPairBusy(true);
try {
// 1. LINK_DEVICE on-chain so senders learn the new pub.
const tx = buildLinkDeviceTx({
from: keyFile.pub_key,
x25519Pub: key,
deviceName: name,
privKey: keyFile.priv_key,
});
await submitTx(tx);
// 2. Ship the handshake payload to the new device. Encrypted for
// its x25519 pub so only it can read — master priv in plaintext
// would be catastrophic, E2E is the whole point.
const payload = JSON.stringify({
v: 1,
type: 'pair-handshake',
code,
master_pub: keyFile.pub_key,
master_priv: keyFile.priv_key,
master_x25519_pub: keyFile.x25519_pub,
});
const { nonce, ciphertext } = encryptMessage(
payload, keyFile.x25519_priv, key,
);
await sendEnvelope({
senderPub: keyFile.x25519_pub,
recipientPub: key,
senderEd25519Pub: keyFile.pub_key,
nonce, ciphertext,
});
// 3. Optimistic local insert so the row shows up without waiting
// for the next pull/refresh round-trip.
setDevices(prev => {
if (prev.some(d => d.x25519_pub_key === key)) return prev;
return [...prev, {
x25519_pub_key: key,
device_name: name,
added_at: Math.floor(Date.now() / 1000),
}];
});
setPairOpen(false);
setPairCode(''); setPairKey(''); setPairName('');
Alert.alert(
'Pairing sent',
'The new device should finish pairing in a few seconds.',
);
} catch (e: any) {
Alert.alert('Pairing failed', humanizeTxError(e));
} finally {
setPairBusy(false);
}
}, [keyFile, pairCode, pairKey, pairName]);
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
title="Devices"
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
/>
<ScrollView
contentContainerStyle={{ padding: 14, paddingBottom: insets.bottom + 30 }}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => load(true)}
tintColor="#1d9bf0"
/>
}
>
<Text style={{ color: '#8b8b8b', fontSize: 12, lineHeight: 17, marginBottom: 14 }}>
Every linked device has its own encryption key. Messages sent to you
are delivered to all active devices.
</Text>
{loading ? (
<View style={{ paddingTop: 60, alignItems: 'center' }}>
<ActivityIndicator color="#1d9bf0" />
</View>
) : devices.length === 0 ? (
<View style={{
paddingTop: 60, alignItems: 'center', paddingHorizontal: 24,
}}>
<Ionicons name="phone-portrait-outline" size={36} color="#3a3a3a" />
<Text style={{
color: '#ffffff', fontSize: 15, fontWeight: '700',
marginTop: 10,
}}>
No devices registered yet
</Text>
<Text style={{
color: '#8b8b8b', fontSize: 13, textAlign: 'center',
marginTop: 6, lineHeight: 19,
}}>
This device auto-registers when the next network-fee is available.
Top up your balance and pull to refresh.
</Text>
</View>
) : (
<View style={{
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
overflow: 'hidden',
}}>
{devices.map((d, i) => {
const isMe = d.x25519_pub_key === meX25519;
const busy = unlinking === d.x25519_pub_key;
return (
<View key={d.x25519_pub_key}>
{i > 0 && <View style={{ height: 1, backgroundColor: '#1f1f1f' }} />}
<View style={{
flexDirection: 'row', alignItems: 'center',
paddingHorizontal: 14, paddingVertical: 14, gap: 12,
}}>
<Ionicons
name={isMe ? 'phone-portrait' : 'phone-portrait-outline'}
size={22}
color={isMe ? '#1d9bf0' : '#d0d0d0'}
/>
<View style={{ flex: 1, minWidth: 0 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text
style={{ color: '#ffffff', fontSize: 15, fontWeight: '700' }}
numberOfLines={1}
>
{d.device_name || 'Unnamed device'}
</Text>
{isMe && (
<View style={{
paddingHorizontal: 6, paddingVertical: 1,
borderRadius: 6, backgroundColor: '#0d2540',
}}>
<Text style={{ color: '#1d9bf0', fontSize: 10, fontWeight: '700' }}>
THIS DEVICE
</Text>
</View>
)}
</View>
<Text
style={{
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
marginTop: 3,
}}
numberOfLines={1}
>
{shortPub(d.x25519_pub_key)}
</Text>
<Text style={{ color: '#6a6a6a', fontSize: 11, marginTop: 2 }}>
Linked {formatDate(d.added_at)}
</Text>
</View>
{!isMe && (
<Pressable
onPress={() => onUnlink(d)}
disabled={busy}
style={({ pressed }) => ({
paddingHorizontal: 12, paddingVertical: 7,
borderRadius: 999,
borderWidth: 1, borderColor: '#3a2020',
backgroundColor: pressed ? '#2a1414' : 'transparent',
opacity: busy ? 0.5 : 1,
})}
>
{busy ? (
<ActivityIndicator size="small" color="#ff6b6b" />
) : (
<Text style={{ color: '#ff6b6b', fontSize: 12, fontWeight: '700' }}>
Unlink
</Text>
)}
</Pressable>
)}
</View>
</View>
);
})}
</View>
)}
{/* Link new device — opens a modal with Code + DeviceKey inputs
that the operator transcribes from the new device's
/auth/pair screen. */}
<View style={{ marginTop: 18 }}>
<Pressable
onPress={() => setPairOpen(true)}
style={({ pressed }) => ({
paddingVertical: 13, paddingHorizontal: 16,
borderRadius: 14,
backgroundColor: pressed ? '#1a1a1a' : '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
flexDirection: 'row', alignItems: 'center', gap: 10,
})}
>
<Ionicons name="link-outline" size={18} color="#1d9bf0" />
<View style={{ flex: 1 }}>
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '700' }}>
Link new device
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>
Enter the 6-digit code from the new device
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color="#6a6a6a" />
</Pressable>
</View>
</ScrollView>
{/* ── Pair new device modal ───────────────────────────────────── */}
<Modal
visible={pairOpen}
animationType="fade"
transparent
onRequestClose={() => !pairBusy && setPairOpen(false)}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={{
flex: 1,
backgroundColor: 'rgba(0,0,0,0.7)',
justifyContent: 'center', alignItems: 'center',
paddingHorizontal: 20,
}}
>
<View style={{
width: '100%', maxWidth: 420,
backgroundColor: '#0a0a0a',
borderRadius: 18,
borderWidth: 1, borderColor: '#1f1f1f',
padding: 20, gap: 12,
}}>
<View style={{
flexDirection: 'row', alignItems: 'center',
justifyContent: 'space-between', marginBottom: 4,
}}>
<Text style={{ color: '#ffffff', fontSize: 18, fontWeight: '800' }}>
Link new device
</Text>
<Pressable
onPress={() => !pairBusy && setPairOpen(false)}
hitSlop={8}
>
<Ionicons name="close" size={22} color="#8b8b8b" />
</Pressable>
</View>
<Text style={{ color: '#8b8b8b', fontSize: 12, lineHeight: 18 }}>
Open the new device, tap <Text style={{ color: '#ffffff' }}>Pair</Text> on
the welcome screen, then transcribe the code + device key shown there.
</Text>
<PairInput
label="6-digit code"
value={pairCode}
onChangeText={setPairCode}
placeholder="000000"
keyboardType="number-pad"
maxLength={6}
monospace
/>
<PairInput
label="Device key"
value={pairKey}
onChangeText={setPairKey}
placeholder="64 hex chars"
autoCapitalize="none"
maxLength={64}
monospace
/>
<PairInput
label="Name for this device (optional)"
value={pairName}
onChangeText={setPairName}
placeholder="e.g. Alice's laptop"
maxLength={64}
/>
<View style={{
flexDirection: 'row', justifyContent: 'flex-end',
gap: 10, marginTop: 8,
}}>
<Pressable
onPress={() => setPairOpen(false)}
disabled={pairBusy}
style={({ pressed }) => ({
paddingHorizontal: 16, paddingVertical: 10,
borderRadius: 999,
backgroundColor: pressed ? '#1a1a1a' : 'transparent',
opacity: pairBusy ? 0.5 : 1,
})}
>
<Text style={{ color: '#8b8b8b', fontSize: 14, fontWeight: '700' }}>
Cancel
</Text>
</Pressable>
<Pressable
onPress={submitPair}
disabled={pairBusy}
style={({ pressed }) => ({
paddingHorizontal: 18, paddingVertical: 10,
borderRadius: 999,
backgroundColor: pressed ? '#1580c8' : '#1d9bf0',
opacity: pairBusy ? 0.7 : 1,
minWidth: 90, alignItems: 'center',
})}
>
{pairBusy ? (
<ActivityIndicator size="small" color="#ffffff" />
) : (
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '700' }}>
Link
</Text>
)}
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
</View>
);
}
function PairInput({
label, value, onChangeText, placeholder, keyboardType, autoCapitalize,
maxLength, monospace,
}: {
label: string;
value: string;
onChangeText: (v: string) => void;
placeholder?: string;
keyboardType?: 'default' | 'number-pad';
autoCapitalize?: 'none' | 'sentences';
maxLength?: number;
monospace?: boolean;
}) {
return (
<View>
<Text style={{
color: '#8b8b8b', fontSize: 11, fontWeight: '700',
textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6,
}}>
{label}
</Text>
<TextInput
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor="#3a3a3a"
keyboardType={keyboardType}
autoCapitalize={autoCapitalize ?? 'sentences'}
autoCorrect={false}
maxLength={maxLength}
style={{
color: '#ffffff', fontSize: monospace ? 13 : 14,
fontFamily: monospace ? 'monospace' : undefined,
backgroundColor: '#000000',
borderRadius: 10,
paddingHorizontal: 12, paddingVertical: 10,
borderWidth: 1, borderColor: '#1f1f1f',
}}
/>
</View>
);
}

View File

@@ -492,6 +492,18 @@ export default function SettingsScreen() {
/> />
</Card> </Card>
{/* ── Devices — multi-device registry (v2.2.0) ── */}
<SectionLabel>Devices</SectionLabel>
<Card>
<Row
icon="phone-portrait-outline"
label="Linked devices"
value="Manage the devices that receive your messages"
onPress={() => router.push('/(app)/devices' as never)}
first
/>
</Card>
{/* ── Account ── */} {/* ── Account ── */}
<SectionLabel>Account</SectionLabel> <SectionLabel>Account</SectionLabel>
<Card> <Card>

View File

@@ -0,0 +1,300 @@
/**
* Pair — secondary-device onboarding.
*
* Flow (new device, this screen):
* 1. Generate a fresh X25519 keypair locally + random 6-digit code.
* 2. Display { code, device x25519 pub }. User enters both on their
* primary device (Settings → Devices → Link new device).
* 3. Primary device:
* - submits LINK_DEVICE tx to publish our pub under its master,
* - sends a relay envelope to our x25519 pub, encrypted with its
* own x25519 priv, containing { code, master_pub, master_priv,
* master_x25519_pub }.
* 4. We poll /relay/inbox every few seconds; when a decryptable
* envelope arrives whose payload.code matches our displayed code,
* we treat it as the handshake, assemble a KeyFile, save it, and
* redirect into (app).
*
* Security notes:
* - Master Ed25519 priv travels only via this envelope, encrypted for
* this device's X25519 pub (which only this device holds the priv
* for). Exposure is limited to one successful decrypt; we DELETE
* the envelope from the mailbox immediately.
* - The 6-digit code defends against a confused primary device paired
* with a different victim, or a race with an attacker who guesses
* our X25519 pub. Envelope without matching code → ignored.
* - Envelope is short-lived in the mailbox: we DELETE on first decrypt
* and the relay node has its own TTL.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
View, Text, Pressable, ActivityIndicator, ScrollView,
} from 'react-native';
import { router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import * as Clipboard from 'expo-clipboard';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import nacl from 'tweetnacl';
import { useStore } from '@/lib/store';
import { bytesToHex, decryptMessage } from '@/lib/crypto';
import { fetchInbox } from '@/lib/api';
import { saveKeyFile, markDeviceRegistered } from '@/lib/storage';
import { safeBack } from '@/lib/utils';
import type { KeyFile } from '@/lib/types';
// Protocol constant — bump if payload shape changes.
const PAIR_ENVELOPE_VERSION = 1;
interface PairEnvelopePayload {
v: number;
type: 'pair-handshake';
code: string;
master_pub: string; // Ed25519 pub, hex
master_priv: string; // Ed25519 priv, hex
master_x25519_pub: string; // primary device's x25519 pub, hex (for nothing special — just FYI)
}
function randomCode(): string {
// Six decimal digits. Entropy ~20 bits. Good enough for a one-shot
// rendezvous code gated by an out-of-band delivery channel (envelope
// targeted at a freshly-generated X25519 pub that only this device
// holds the priv for).
const n = Math.floor(Math.random() * 1_000_000);
return n.toString().padStart(6, '0');
}
export default function PairScreen() {
const insets = useSafeAreaInsets();
const setKeyFile = useStore(s => s.setKeyFile);
// One-shot keypair + code for this pairing session. Regenerate only on
// manual Retry (unmount+remount).
const session = useRef(genSession()).current;
const [status, setStatus] = useState<'waiting' | 'success' | 'error'>('waiting');
const [err, setErr] = useState<string | null>(null);
const copyCode = useCallback(async () => {
await Clipboard.setStringAsync(session.code);
}, [session.code]);
const copyPub = useCallback(async () => {
await Clipboard.setStringAsync(session.x25519Pub);
}, [session.x25519Pub]);
// Poll mailbox until a matching handshake envelope arrives, or user
// backs out. Interval 2.5s — conservative on battery, fine for a
// flow the user is staring at for a minute.
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const tick = async () => {
if (cancelled) return;
try {
const envs = await fetchInbox(session.x25519Pub);
for (const env of envs) {
// Decrypt each envelope with our session priv. We don't know
// the primary's x25519 pub up front — it's inside the envelope
// metadata. decryptMessage needs both pubs, so we pass the
// envelope's sender_pub directly.
const plain = decryptMessage(
env.ciphertext, env.nonce, env.sender_pub, session.x25519Priv,
);
if (!plain) continue;
let payload: PairEnvelopePayload;
try {
payload = JSON.parse(plain);
} catch { continue; }
if (
payload.v !== PAIR_ENVELOPE_VERSION ||
payload.type !== 'pair-handshake' ||
payload.code !== session.code ||
!payload.master_pub || !payload.master_priv
) continue;
// Success — materialise a KeyFile and redirect.
const kf: KeyFile = {
pub_key: payload.master_pub,
priv_key: payload.master_priv,
x25519_pub: session.x25519Pub,
x25519_priv: session.x25519Priv,
};
await saveKeyFile(kf);
// The root layout auto-links us on first boot if needed, but
// the primary device already submitted LINK_DEVICE for our
// pub as part of the pairing, so the registry is already
// correct. Mark ourselves registered so the revoke-detection
// branch doesn't spuriously wipe on next launch.
await markDeviceRegistered();
setKeyFile(kf);
// Envelope stays in mailbox until relay TTL eviction; the
// single-shot handshake is idempotent (saveKeyFile overwrites)
// and our session pub won't be polled again after redirect.
setStatus('success');
setTimeout(() => {
if (!cancelled) router.replace('/(app)/chats' as never);
}, 600);
return;
}
} catch {
/* transient — retry */
}
if (!cancelled) timer = setTimeout(tick, 2_500);
};
tick();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [session, setKeyFile]);
return (
<View style={{
flex: 1, backgroundColor: '#000000',
paddingTop: insets.top + 12,
paddingBottom: Math.max(insets.bottom, 20),
}}>
<ScrollView
contentContainerStyle={{ padding: 24 }}
showsVerticalScrollIndicator={false}
>
{/* Header */}
<Pressable
onPress={() => safeBack('/')}
hitSlop={8}
style={{ alignSelf: 'flex-start', marginBottom: 20 }}
>
<Ionicons name="chevron-back" size={28} color="#ffffff" />
</Pressable>
<View style={{ alignItems: 'center', marginBottom: 28 }}>
<View style={{
width: 80, height: 80, borderRadius: 22,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
alignItems: 'center', justifyContent: 'center',
}}>
<Ionicons name="link" size={40} color="#1d9bf0" />
</View>
<Text style={{
color: '#ffffff', fontSize: 22, fontWeight: '800',
marginTop: 14, textAlign: 'center',
}}>
Pair with your other device
</Text>
<Text style={{
color: '#8b8b8b', fontSize: 13, lineHeight: 19,
marginTop: 8, textAlign: 'center', maxWidth: 300,
}}>
On a device where you're already signed in,
open Settings → Devices → Link new device,
and enter these values.
</Text>
</View>
{/* Code */}
<View style={{
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
paddingVertical: 18, paddingHorizontal: 16,
marginBottom: 14, alignItems: 'center',
}}>
<Text style={{
color: '#5a5a5a', fontSize: 11, fontWeight: '700',
textTransform: 'uppercase', letterSpacing: 1.2,
}}>
1. Code
</Text>
<Text style={{
color: '#ffffff', fontSize: 38, fontWeight: '800',
letterSpacing: 6, marginTop: 4, fontFamily: 'monospace',
}}>
{session.code.slice(0, 3)} {session.code.slice(3)}
</Text>
<Pressable onPress={copyCode} hitSlop={6} style={{ marginTop: 6 }}>
<Text style={{ color: '#1d9bf0', fontSize: 12, fontWeight: '600' }}>
Copy code
</Text>
</Pressable>
</View>
{/* Device key */}
<View style={{
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
paddingVertical: 16, paddingHorizontal: 16,
marginBottom: 20,
}}>
<Text style={{
color: '#5a5a5a', fontSize: 11, fontWeight: '700',
textTransform: 'uppercase', letterSpacing: 1.2,
}}>
2. Device key
</Text>
<Text
selectable
style={{
color: '#ffffff', fontSize: 12, fontFamily: 'monospace',
marginTop: 6, lineHeight: 17,
}}
>
{session.x25519Pub}
</Text>
<Pressable onPress={copyPub} hitSlop={6} style={{ marginTop: 8 }}>
<Text style={{ color: '#1d9bf0', fontSize: 12, fontWeight: '600' }}>
Copy key
</Text>
</Pressable>
</View>
{/* Status */}
<View style={{ alignItems: 'center', minHeight: 56 }}>
{status === 'waiting' && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<ActivityIndicator size="small" color="#1d9bf0" />
<Text style={{ color: '#8b8b8b', fontSize: 13 }}>
Waiting for your other device…
</Text>
</View>
)}
{status === 'success' && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<Ionicons name="checkmark-circle" size={20} color="#3ba55d" />
<Text style={{ color: '#3ba55d', fontSize: 13, fontWeight: '700' }}>
Paired. Opening your chats…
</Text>
</View>
)}
{status === 'error' && (
<Text style={{ color: '#f4212e', fontSize: 13, textAlign: 'center' }}>
{err ?? 'Something went wrong. Please retry.'}
</Text>
)}
</View>
</ScrollView>
</View>
);
}
// ─── session helper ──────────────────────────────────────────────────────
interface PairSession {
x25519Pub: string; // hex
x25519Priv: string; // hex
code: string;
}
function genSession(): PairSession {
const kp = nacl.box.keyPair();
return {
x25519Pub: bytesToHex(kp.publicKey),
x25519Priv: bytesToHex(kp.secretKey),
code: randomCode(),
};
}

View File

@@ -346,8 +346,13 @@ export default function WelcomeScreen() {
{/* CTA — прижата к правому нижнему краю. */} {/* CTA — прижата к правому нижнему краю. */}
<View style={{ <View style={{
flexDirection: 'row', justifyContent: 'flex-end', gap: 10, flexDirection: 'row', justifyContent: 'flex-end', gap: 10,
paddingHorizontal: 24, paddingBottom: 8, paddingHorizontal: 24, paddingBottom: 8, flexWrap: 'wrap',
}}> }}>
<CTASecondary
label="Pair"
icon="link"
onPress={() => router.push('/(auth)/pair' as never)}
/>
<CTASecondary <CTASecondary
label="Import" label="Import"
onPress={() => router.push('/(auth)/import' as never)} onPress={() => router.push('/(auth)/import' as never)}

View File

@@ -365,6 +365,25 @@ export interface IdentityInfo {
x25519_pub: string; // hex Curve25519 key; empty string if not published x25519_pub: string; // hex Curve25519 key; empty string if not published
nickname: string; nickname: string;
registered: boolean; registered: boolean;
/**
* Number of active (non-revoked) devices linked to this master identity
* via LINK_DEVICE (v2.2.0). 0 for legacy identities that only published
* a single X25519 via REGISTER_KEY — senders should fall back to
* `x25519_pub` above and skip the device fan-out path.
*/
device_count?: number;
}
/**
* One active device in an identity's multi-device registry. Returned by
* GET /api/devices/{master_pub} as part of `devices[]`. Senders use the
* list to fan out one sealed envelope per X25519 pub so all of the
* recipient's devices receive the message.
*/
export interface DeviceInfo {
x25519_pub_key: string;
device_name: string;
added_at: number; // unix seconds
} }
/** /**
@@ -409,6 +428,56 @@ export async function getIdentity(pubkeyOrAddr: string): Promise<IdentityInfo |
} }
} }
// ─── Multi-device registry (v2.2.0) ───────────────────────────────────────
interface DevicesResponse {
master_pub: string;
count: number;
devices: DeviceInfo[];
}
/**
* GET /api/devices/{master_pub} — all active (non-revoked) device records
* for the given master identity. Returns an empty array for a legacy
* identity (device_count == 0) or a network error — callers should treat
* both the same way and fall back to IdentityInfo.x25519_pub so the
* pre-v2.2.0 single-device path keeps working.
*/
export async function fetchDevices(masterPub: string): Promise<DeviceInfo[]> {
try {
const resp = await get<DevicesResponse>(`/api/devices/${masterPub}`);
return resp.devices ?? [];
} catch {
return [];
}
}
/**
* Pick the right set of recipient X25519 pubs for a sender's fan-out.
* Two paths, in priority order:
*
* 1. New path — /api/devices returns ≥1 entry. Send to each device.
* 2. Legacy path — identity published an X25519 via REGISTER_KEY
* (pre-v2.2.0 clients). Send to just that one.
*
* Returns an empty array only when the recipient has published nothing
* at all — caller must surface "no encryption key" to the user rather
* than drop the message on the floor.
*/
export async function resolveRecipientKeys(
recipientMasterPub: string,
): Promise<string[]> {
const devs = await fetchDevices(recipientMasterPub);
if (devs.length > 0) {
return devs.map(d => d.x25519_pub_key);
}
const identity = await getIdentity(recipientMasterPub);
if (identity?.x25519_pub) {
return [identity.x25519_pub];
}
return [];
}
// ─── Contract API ───────────────────────────────────────────────────────────── // ─── Contract API ─────────────────────────────────────────────────────────────
/** /**
@@ -712,6 +781,68 @@ export function buildTransferTx(params: {
}; };
} }
/**
* LINK_DEVICE transaction — publish a per-device X25519 pub in the
* identity's device registry so senders can fan out envelopes across
* every active device. Signed by the master Ed25519 (= `from`).
*
* `deviceName` is a short human label shown in Settings → Devices
* (≤ 64 bytes, printable ASCII/UTF-8, no control chars).
*/
export function buildLinkDeviceTx(params: {
from: string; // master Ed25519 pubkey
x25519Pub: string; // per-device X25519 pubkey (64 hex chars, lowercase)
deviceName: string;
privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payloadObj = {
x25519_pub_key: params.x25519Pub,
device_name: params.deviceName,
};
const payload = strToBase64(JSON.stringify(payloadObj));
const canonical = txCanonicalBytes({
id, type: 'LINK_DEVICE', from: params.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'LINK_DEVICE', from: params.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
/**
* UNLINK_DEVICE transaction — revoke a previously-linked device so senders
* stop shipping envelopes to its X25519 pub. The revoked device itself,
* when it next comes online and sees its own pub in the revoked list,
* is expected to wipe local state (master priv + cached chats).
*/
export function buildUnlinkDeviceTx(params: {
from: string; // master Ed25519 pubkey
x25519Pub: string; // pub to revoke
privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payloadObj = { x25519_pub_key: params.x25519Pub };
const payload = strToBase64(JSON.stringify(payloadObj));
const canonical = txCanonicalBytes({
id, type: 'UNLINK_DEVICE', from: params.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'UNLINK_DEVICE', from: params.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
/** /**
* CONTACT_REQUEST transaction. * CONTACT_REQUEST transaction.
* *

View File

@@ -14,6 +14,12 @@ const KEYFILE_KEY = 'dchain_keyfile';
const CONTACTS_KEY = 'dchain_contacts'; const CONTACTS_KEY = 'dchain_contacts';
const SETTINGS_KEY = 'dchain_settings'; const SETTINGS_KEY = 'dchain_settings';
const CHATS_KEY = 'dchain_chats'; const CHATS_KEY = 'dchain_chats';
// Remembers (locally, per install) that this device's X25519 pub has been
// successfully linked on-chain at least once. Distinguishes "first boot,
// not registered yet" from "we were registered and then revoked by another
// device". The second case triggers self-wipe. Stored in AsyncStorage —
// if it's missing, we simply re-link.
const DEVICE_REGISTERED_KEY = 'dchain_device_registered';
/** Save the key file in secure storage (encrypted on device). */ /** Save the key file in secure storage (encrypted on device). */
export async function saveKeyFile(kf: KeyFile): Promise<void> { export async function saveKeyFile(kf: KeyFile): Promise<void> {
@@ -99,3 +105,57 @@ export async function appendMessage(chatId: string, msg: CachedMessage): Promise
const trimmed = msgs.slice(-500); const trimmed = msgs.slice(-500);
await AsyncStorage.setItem(`${CHATS_KEY}_${chatId}`, JSON.stringify(trimmed)); await AsyncStorage.setItem(`${CHATS_KEY}_${chatId}`, JSON.stringify(trimmed));
} }
// ─── Multi-device bookkeeping (v2.2.0) ────────────────────────────────────
/**
* isDeviceRegistered returns true if this device has ever successfully
* linked its X25519 pub on-chain under the current master identity.
* A true-then-absent transition (registered → not in chain's active list)
* is interpreted as a remote revoke and triggers self-wipe.
*/
export async function isDeviceRegistered(): Promise<boolean> {
return (await AsyncStorage.getItem(DEVICE_REGISTERED_KEY)) === '1';
}
/** markDeviceRegistered is called after a LINK_DEVICE commits or is
observed in the registry on startup. */
export async function markDeviceRegistered(): Promise<void> {
await AsyncStorage.setItem(DEVICE_REGISTERED_KEY, '1');
}
/** clearDeviceRegistered is part of wipeAllLocalState; also called on
explicit logout. */
export async function clearDeviceRegistered(): Promise<void> {
await AsyncStorage.removeItem(DEVICE_REGISTERED_KEY);
}
/**
* wipeAllLocalState zeroes out every on-device artifact tied to the
* current identity: secure-store key, settings, contacts, chats cache,
* registered-device marker. Safe to call multiple times.
*
* Called in two scenarios:
* 1. Explicit "Delete account" in Settings.
* 2. Self-detected revoke — the chain says our X25519 pub is no longer
* in the active registry but we previously marked it registered,
* so another device issued UNLINK_DEVICE against us. We must not
* keep using the master priv any more — it still works at the
* crypto level, but the social contract is that we're revoked.
*/
export async function wipeAllLocalState(): Promise<void> {
// Secure store (key).
await SecureStore.deleteItemAsync(KEYFILE_KEY).catch(() => {});
// AsyncStorage — enumerate our known keys. We don't clear() the whole
// store because a share-provider or other app shard could live there.
const ks = await AsyncStorage.getAllKeys();
const ours = ks.filter(k =>
k === CONTACTS_KEY ||
k === SETTINGS_KEY ||
k === DEVICE_REGISTERED_KEY ||
k.startsWith(`${CHATS_KEY}_`),
);
if (ours.length > 0) {
await AsyncStorage.multiRemove(ours);
}
}