Compare commits
1 Commits
v2.2.0-alp
...
v2.2.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
423d307125 |
@@ -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';
|
||||||
@@ -26,6 +26,9 @@ 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 } 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 +76,44 @@ export default function AppLayout() {
|
|||||||
else ws.setAuthCreds(null);
|
else ws.setAuthCreds(null);
|
||||||
}, [keyFile]);
|
}, [keyFile]);
|
||||||
|
|
||||||
|
// Multi-device registry bootstrap (v2.2.0). On every sign-in:
|
||||||
|
// 1. Fetch our own device list from chain.
|
||||||
|
// 2. If our local X25519 pub isn't in the active set, submit a
|
||||||
|
// LINK_DEVICE tx for it — this makes "this device" discoverable
|
||||||
|
// to senders. No-ops after the first successful submission on
|
||||||
|
// a given chain + master key pair.
|
||||||
|
// Failures are swallowed: insufficient balance, offline node, or a
|
||||||
|
// chain that hasn't upgraded to v2.2.0 all surface the same way
|
||||||
|
// (our device just isn't registered yet; next sign-in tries again).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const devs = await fetchDevices(keyFile.pub_key);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (devs.some(d => d.x25519_pub_key === keyFile.x25519_pub)) {
|
||||||
|
return; // already registered
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
} catch {
|
||||||
|
/* best-effort — next launch retries */
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [keyFile]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (keyFile === null) {
|
if (keyFile === null) {
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user