improve mobile sync security

This commit is contained in:
Boof2015
2026-07-13 19:31:11 -04:00
parent 4e98bd5b8e
commit e496df9bf5
24 changed files with 1489 additions and 313 deletions
+30 -6
View File
@@ -34,6 +34,7 @@ import {
setDesktopRemoteMediaSession,
subscribeDesktopRemoteMediaSessionCommands,
} from '@/services/desktopRemoteMediaSession';
import { identityMatchesPinnedConnection } from '@/services/desktopSyncPolicy';
import {
getDesktopRemoteConnection,
setDesktopRemoteConnection,
@@ -170,6 +171,8 @@ const DESKTOP_SYNC_REQUEST_POLL_MS = 60_000;
function DesktopSyncAutoTrigger() {
const connectionState = useDesktopRemoteStore((s) => s.connectionState);
const discovered = useDesktopRemoteStore((s) => s.discovered);
const desktopSyncHydrated = useDesktopSyncStore((s) => s.hydrated);
const desktopSyncEnabled = useDesktopSyncStore((s) => s.desktopSyncEnabled);
useEffect(() => {
void useDesktopSyncStore.getState().hydrate();
@@ -182,6 +185,8 @@ function DesktopSyncAutoTrigger() {
let startupRetryTimer: ReturnType<typeof setTimeout> | null = null;
const onActive = () => {
void (async () => {
const sync = useDesktopSyncStore.getState();
if (!sync.hydrated || !sync.desktopSyncEnabled) return;
const connection = await getDesktopRemoteConnection();
if (!connection) return;
useDesktopSyncStore.getState().maybeAutoSync('foreground');
@@ -210,31 +215,41 @@ function DesktopSyncAutoTrigger() {
subscription.remove();
if (burstTimer !== null) clearTimeout(burstTimer);
if (startupRetryTimer !== null) clearTimeout(startupRetryTimer);
if (!useDesktopSyncStore.getState().desktopSyncEnabled) {
void useDesktopRemoteStore.getState().stopDiscovery();
}
};
}, []);
}, [desktopSyncEnabled, desktopSyncHydrated]);
// Desktop-initiated "Sync now" pickup: a cheap identity poll while
// foregrounded (the SSE nudge only reaches us while the remote screen's
// stream happens to be connected). fetchDesktopRemoteIdentity swallows
// errors, so a powered-off desktop costs one timed-out request per minute.
useEffect(() => {
if (!desktopSyncHydrated || !desktopSyncEnabled) return;
const timer = setInterval(() => {
if (AppState.currentState !== 'active') return;
void (async () => {
const sync = useDesktopSyncStore.getState();
if (!sync.hydrated || !sync.desktopSyncEnabled) return;
const connection = await getDesktopRemoteConnection();
if (!connection) return;
const identity = await fetchDesktopRemoteIdentity(connection.baseUrl);
const identity = await fetchDesktopRemoteIdentity(
connection.baseUrl,
connection.certificateFingerprint
);
if (identity?.syncRequestedAt) {
useDesktopSyncStore.getState().handleSyncRequest();
}
})();
}, DESKTOP_SYNC_REQUEST_POLL_MS);
return () => clearInterval(timer);
}, []);
}, [desktopSyncEnabled, desktopSyncHydrated]);
// Paired desktop spotted on the LAN: refresh a stale baseUrl (DHCP moves)
// and trigger a sync.
useEffect(() => {
if (!desktopSyncHydrated || !desktopSyncEnabled) return;
if (discovered.length === 0) return;
void (async () => {
const connection = await getDesktopRemoteConnection();
@@ -242,6 +257,15 @@ function DesktopSyncAutoTrigger() {
const match = discovered.find((desktop) => desktop.endpointUuid === connection.endpointUuid);
if (!match) return;
if (match.baseUrl && match.baseUrl !== connection.baseUrl) {
const identity = await fetchDesktopRemoteIdentity(
match.baseUrl,
connection.certificateFingerprint
);
if (!identity || !identityMatchesPinnedConnection(
connection.endpointUuid,
identity.protocolVersion,
identity.endpointUuid
)) return;
const updated = { ...connection, baseUrl: match.baseUrl };
await setDesktopRemoteConnection(updated);
if (useDesktopRemoteStore.getState().connection) {
@@ -250,14 +274,14 @@ function DesktopSyncAutoTrigger() {
}
useDesktopSyncStore.getState().maybeAutoSync('discovery');
})();
}, [discovered]);
}, [desktopSyncEnabled, desktopSyncHydrated, discovered]);
// The remote screen connected — the desktop is definitely reachable.
useEffect(() => {
if (connectionState === 'connected') {
if (desktopSyncHydrated && desktopSyncEnabled && connectionState === 'connected') {
useDesktopSyncStore.getState().maybeAutoSync('connected');
}
}, [connectionState]);
}, [desktopSyncEnabled, desktopSyncHydrated, connectionState]);
return null;
}
+48 -21
View File
@@ -225,7 +225,20 @@ export default function DesktopRemoteScreen() {
const ripple = useRipple();
const colors = useColors();
const router = useRouter();
const { pair } = useLocalSearchParams<{ pair?: string }>();
const pairingParams = useLocalSearchParams<{
pair?: string;
baseUrl?: string;
ticket?: string;
endpointUuid?: string;
fingerprint?: string;
protocolVersion?: string;
}>();
const pairingRoutePair = pairingParams.pair;
const pairingRouteBaseUrl = pairingParams.baseUrl;
const pairingRouteTicket = pairingParams.ticket;
const pairingRouteEndpointUuid = pairingParams.endpointUuid;
const pairingRouteFingerprint = pairingParams.fingerprint;
const pairingRouteProtocolVersion = pairingParams.protocolVersion;
const insets = useSafeAreaInsets();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const initialized = useDesktopRemoteStore((s) => s.initialized);
@@ -253,7 +266,6 @@ export default function DesktopRemoteScreen() {
const [pinInput, setPinInput] = useState('');
const [pinClock, setPinClock] = useState(() => Date.now());
const [manualBaseUrl, setManualBaseUrl] = useState('');
const [manualTicket, setManualTicket] = useState('');
useEffect(() => {
void init();
@@ -267,11 +279,35 @@ export default function DesktopRemoteScreen() {
}, [startDiscovery, stopDiscovery]);
useEffect(() => {
if (typeof pair === 'string' && pair.trim()) {
void pairFromInput(pair);
if (typeof pairingRoutePair === 'string' && pairingRoutePair.trim()) {
void pairFromInput(pairingRoutePair);
router.setParams({ pair: undefined });
return;
}
}, [pair, pairFromInput, router]);
if (
typeof pairingRouteBaseUrl === 'string' && typeof pairingRouteTicket === 'string' &&
typeof pairingRouteEndpointUuid === 'string' && typeof pairingRouteFingerprint === 'string' &&
pairingRouteProtocolVersion === '3'
) {
const url = new URL('astra://desktop-remote');
url.searchParams.set('baseUrl', pairingRouteBaseUrl);
url.searchParams.set('ticket', pairingRouteTicket);
url.searchParams.set('endpointUuid', pairingRouteEndpointUuid);
url.searchParams.set('fingerprint', pairingRouteFingerprint);
url.searchParams.set('protocolVersion', '3');
void pairFromInput(url.toString());
router.setParams({ baseUrl: undefined, ticket: undefined, endpointUuid: undefined, fingerprint: undefined, protocolVersion: undefined });
}
}, [
pairFromInput,
pairingRouteBaseUrl,
pairingRouteEndpointUuid,
pairingRouteFingerprint,
pairingRoutePair,
pairingRouteProtocolVersion,
pairingRouteTicket,
router,
]);
useEffect(() => {
if (!pinPairing) return undefined;
@@ -453,40 +489,31 @@ export default function DesktopRemoteScreen() {
<Text variant="body">Manual fallback</Text>
</View>
<Text variant="caption" color={colors.textSecondary} style={styles.cardCopy}>
Enter the desktop URL and pairing code from Astra Desktop.
Enter the Astra Desktop HTTPS URL, then compare the six-digit code shown on both devices.
</Text>
<TextInput
style={styles.input}
value={manualBaseUrl}
onChangeText={setManualBaseUrl}
placeholder="http://desktop-ip:38402"
placeholder="https://desktop-ip:38402"
placeholderTextColor={colors.textTertiary}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
<TextInput
style={styles.input}
value={manualTicket}
onChangeText={setManualTicket}
placeholder="Pairing code"
placeholderTextColor={colors.textTertiary}
autoCapitalize="none"
autoCorrect={false}
/>
<Pressable android_ripple={ripple.bounded}
style={[
styles.secondaryButton,
(!manualBaseUrl.trim() || !manualTicket.trim()) && styles.buttonDisabled,
!manualBaseUrl.trim() && styles.buttonDisabled,
]}
disabled={!manualBaseUrl.trim() || !manualTicket.trim()}
onPress={() => void pairManual(manualBaseUrl, manualTicket)}
disabled={!manualBaseUrl.trim()}
onPress={() => void pairManual(manualBaseUrl)}
>
<Text
variant="body"
color={manualBaseUrl.trim() && manualTicket.trim() ? colors.textPrimary : colors.textTertiary}
color={manualBaseUrl.trim() ? colors.textPrimary : colors.textTertiary}
>
Pair manually
Request secure PIN
</Text>
</Pressable>
</View>
+9 -9
View File
@@ -140,9 +140,9 @@ export default function DesktopSyncScreen() {
const lastSummary = useDesktopSyncStore((s) => s.lastSummary);
const conflicts = useDesktopSyncStore((s) => s.conflicts);
const errorMessage = useDesktopSyncStore((s) => s.errorMessage);
const autoSyncEnabled = useDesktopSyncStore((s) => s.autoSyncEnabled);
const desktopSyncEnabled = useDesktopSyncStore((s) => s.desktopSyncEnabled);
const syncNow = useDesktopSyncStore((s) => s.syncNow);
const setAutoSyncEnabled = useDesktopSyncStore((s) => s.setAutoSyncEnabled);
const setDesktopSyncEnabled = useDesktopSyncStore((s) => s.setDesktopSyncEnabled);
const resolveConflict = useDesktopSyncStore((s) => s.resolveConflict);
const [connection, setConnection] = useState<DesktopRemoteConnection | null>(null);
@@ -229,8 +229,8 @@ export default function DesktopSyncScreen() {
</Text>
</View>
<Pressable android_ripple={ripple.bounded}
style={[styles.primaryButton, syncing && styles.disabled]}
disabled={syncing}
style={[styles.primaryButton, (syncing || !desktopSyncEnabled) && styles.disabled]}
disabled={syncing || !desktopSyncEnabled}
onPress={() => void syncNow()}
accessibilityLabel="Sync favorites and playlists now"
>
@@ -259,15 +259,15 @@ export default function DesktopSyncScreen() {
<View style={styles.card}>
<View style={styles.toggleRow}>
<View style={styles.toggleText}>
<Text variant="body">Sync automatically</Text>
<Text variant="body">Desktop Sync</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.cardCopy}>
Sync when this desktop appears on the network or the app returns to the
foreground. Manual and desktop-requested syncs always work.
Allow favorites and playlists to sync securely with this desktop on any
network. Turn this off to suppress every sync trigger without forgetting it.
</Text>
</View>
<HapticSwitch
value={autoSyncEnabled}
onValueChange={(value) => void setAutoSyncEnabled(value)}
value={desktopSyncEnabled}
onValueChange={(value) => void setDesktopSyncEnabled(value)}
trackColor={{ false: colors.glassBorder, true: colors.accent }}
thumbColor={colors.textPrimary}
/>