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
+160 -164
View File
@@ -1,4 +1,8 @@
import * as Device from 'expo-device';
import {
AstraDesktopTransport,
desktopPinnedTransportAvailable,
} from '../../modules/astra-desktop-transport';
import type {
DesktopRemoteControlCommand,
DesktopRemoteIdentity,
@@ -26,7 +30,7 @@ interface JsonRequestOptions {
token?: string | null;
body?: unknown;
timeoutMs?: number;
signal?: AbortSignal;
fingerprint: string;
}
export class DesktopRemoteHttpError extends Error {
@@ -40,52 +44,26 @@ export class DesktopRemoteHttpError extends Error {
}
}
function timeoutSignal(timeoutMs: number, parentSignal?: AbortSignal): AbortSignal {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const abort = () => controller.abort();
if (parentSignal) {
if (parentSignal.aborted) controller.abort();
else parentSignal.addEventListener('abort', abort, { once: true });
}
controller.signal.addEventListener(
'abort',
() => {
clearTimeout(timer);
parentSignal?.removeEventListener('abort', abort);
},
{ once: true }
);
return controller.signal;
}
async function fetchJson<T>(
baseUrl: string,
path: string,
options: JsonRequestOptions = {}
options: JsonRequestOptions
): Promise<T> {
const headers: Record<string, string> = {
Accept: 'application/json',
};
let body: string | undefined;
if (options.body !== undefined) {
headers['Content-Type'] = 'application/json; charset=utf-8';
body = JSON.stringify(options.body);
if (!desktopPinnedTransportAvailable || !AstraDesktopTransport) {
throw new Error('Secure Desktop Remote transport is unavailable on this device.');
}
if (options.token) headers.Authorization = `Bearer ${options.token}`;
const response = await fetch(`${baseUrl}${path}`, {
method: options.method ?? (body ? 'POST' : 'GET'),
headers,
const body = options.body === undefined ? null : JSON.stringify(options.body);
const response = await AstraDesktopTransport.requestJson(
baseUrl,
path,
options.method ?? (body ? 'POST' : 'GET'),
body,
cache: 'no-store',
signal: timeoutSignal(options.timeoutMs ?? REQUEST_TIMEOUT_MS, options.signal),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
options.token ?? null,
options.fingerprint,
options.timeoutMs ?? REQUEST_TIMEOUT_MS
);
const payload = response.body ? JSON.parse(response.body) as unknown : null;
if (response.status < 200 || response.status >= 300) {
const message =
payload && typeof payload === 'object' && 'error' in payload && typeof payload.error === 'string'
? payload.error
@@ -127,9 +105,12 @@ export function defaultDesktopRemoteDeviceName(): string {
return model ? `${model} Remote` : 'Astra Mobile Remote';
}
export async function fetchDesktopRemoteIdentity(baseUrl: string): Promise<DesktopRemoteIdentity | null> {
export async function fetchDesktopRemoteIdentity(
baseUrl: string,
certificateFingerprint: string
): Promise<DesktopRemoteIdentity | null> {
try {
const payload = await fetchJson<unknown>(baseUrl, '/v1/identity');
const payload = await fetchJson<unknown>(baseUrl, '/v1/identity', { fingerprint: certificateFingerprint });
return normalizeIdentity(payload);
} catch {
return null;
@@ -139,9 +120,11 @@ export async function fetchDesktopRemoteIdentity(baseUrl: string): Promise<Deskt
export async function claimDesktopRemotePairingTicket(
baseUrl: string,
ticket: string,
certificateFingerprint: string,
deviceName: string = defaultDesktopRemoteDeviceName()
): Promise<DesktopRemotePairingClaim> {
const payload = await fetchJson<Record<string, unknown>>(baseUrl, '/v1/pairing/claim', {
fingerprint: certificateFingerprint,
method: 'POST',
body: {
ticket,
@@ -163,58 +146,67 @@ export async function requestDesktopRemotePinPairing(
baseUrl: string,
deviceName: string = defaultDesktopRemoteDeviceName()
): Promise<DesktopRemotePinPairingRequest> {
const payload = await fetchJson<Record<string, unknown>>(baseUrl, '/v1/pairing/pin-request', {
method: 'POST',
body: {
deviceName,
clientLabel: clientLabel(),
},
});
if (!desktopPinnedTransportAvailable || !AstraDesktopTransport) {
throw new Error('Secure Desktop Remote transport is unavailable on this device.');
}
const payload = await AstraDesktopTransport.beginPinPairing(baseUrl, deviceName, clientLabel());
return {
requestId: String(payload.requestId ?? ''),
pollToken: String(payload.pollToken ?? ''),
expiresAt: typeof payload.expiresAt === 'number' ? payload.expiresAt : 0,
deviceName: String(payload.deviceName ?? deviceName),
clientLabel: String(payload.clientLabel ?? clientLabel()),
identity: normalizeIdentity(payload.identity ?? payload),
attemptId: payload.attemptId,
requestId: payload.requestId,
pollToken: '',
expiresAt: payload.expiresAt,
deviceName,
clientLabel: clientLabel(),
identity: { endpointUuid: null, desktopName: payload.desktopName, protocolVersion: 3 },
certificateFingerprint: payload.certificateFingerprint,
protocolVersion: 3,
};
}
export async function confirmDesktopRemotePinPairing(
baseUrl: string,
requestId: string,
attemptId: string,
pin: string
): Promise<DesktopRemotePairingStatus> {
const payload = await fetchJson<Record<string, unknown>>(baseUrl, '/v1/pairing/pin-confirm', {
method: 'POST',
body: {
requestId,
pin,
},
});
const state = typeof payload.state === 'string' ? payload.state : 'approved';
if (!desktopPinnedTransportAvailable || !AstraDesktopTransport) {
throw new Error('Secure Desktop Remote transport is unavailable on this device.');
}
const payload = await AstraDesktopTransport.confirmPinPairing(attemptId, pin);
return {
state: state as DesktopRemotePairingStatus['state'],
expiresAt: typeof payload.expiresAt === 'number' ? payload.expiresAt : 0,
token: typeof payload.token === 'string' ? payload.token : undefined,
deviceId: typeof payload.deviceId === 'string' ? payload.deviceId : null,
identity: normalizeIdentity(payload.identity ?? payload),
state: 'approved',
expiresAt: 0,
token: payload.controlToken,
controlToken: payload.controlToken,
syncToken: payload.syncToken,
issuedAt: payload.issuedAt,
scopes: ['control', 'sync'],
certificateFingerprint: payload.certificateFingerprint,
deviceId: payload.deviceId,
identity: normalizeIdentity(JSON.parse(payload.identityJson)),
};
}
export async function fetchDesktopRemotePairingStatus(
baseUrl: string,
pollToken: string
pollToken: string,
certificateFingerprint: string
): Promise<DesktopRemotePairingStatus> {
const payload = await fetchJson<Record<string, unknown>>(
baseUrl,
`/v1/pairing/status?pollToken=${encodeURIComponent(pollToken)}`
`/v1/pairing/status?pollToken=${encodeURIComponent(pollToken)}`,
{ fingerprint: certificateFingerprint }
);
const state = typeof payload.state === 'string' ? payload.state : 'pending';
return {
state: state as DesktopRemotePairingStatus['state'],
expiresAt: typeof payload.expiresAt === 'number' ? payload.expiresAt : 0,
token: typeof payload.token === 'string' ? payload.token : undefined,
controlToken: typeof payload.controlToken === 'string' ? payload.controlToken : undefined,
syncToken: typeof payload.syncToken === 'string' ? payload.syncToken : undefined,
issuedAt: typeof payload.issuedAt === 'number' ? payload.issuedAt : undefined,
scopes: Array.isArray(payload.scopes)
? payload.scopes.filter((scope): scope is 'control' | 'sync' => scope === 'control' || scope === 'sync')
: undefined,
certificateFingerprint: typeof payload.certificateFingerprint === 'string' ? payload.certificateFingerprint : undefined,
deviceId: typeof payload.deviceId === 'string' ? payload.deviceId : null,
identity: normalizeIdentity(payload.identity ?? payload),
};
@@ -223,24 +215,27 @@ export async function fetchDesktopRemotePairingStatus(
export async function fetchDesktopRemoteNowPlaying(
baseUrl: string,
token: string,
certificateFingerprint: string,
inlineArtwork = false
): Promise<DesktopRemoteNowPlayingSnapshot> {
return fetchJson<DesktopRemoteNowPlayingSnapshot>(
baseUrl,
`/v1/now-playing${inlineArtwork ? '?inlineArtwork=1' : ''}`,
{ token }
{ token, fingerprint: certificateFingerprint }
);
}
export async function sendDesktopRemoteControl(
baseUrl: string,
token: string,
certificateFingerprint: string,
command: DesktopRemoteControlCommand,
time?: number
): Promise<void> {
await fetchJson<{ ok: true }>(baseUrl, '/v1/control', {
method: 'POST',
token,
fingerprint: certificateFingerprint,
body: command === 'seek' ? { command, time } : { command },
});
}
@@ -248,38 +243,87 @@ export async function sendDesktopRemoteControl(
export async function sendDesktopRemotePlayQueueItem(
baseUrl: string,
token: string,
certificateFingerprint: string,
queueId: string
): Promise<void> {
await fetchJson<{ ok: true }>(baseUrl, '/v1/control', {
method: 'POST',
token,
fingerprint: certificateFingerprint,
body: { command: 'play-queue-item', queueId },
});
}
export async function fetchDesktopRemoteQueue(
baseUrl: string,
token: string
token: string,
certificateFingerprint: string
): Promise<DesktopRemoteQueueSnapshot> {
return fetchJson<DesktopRemoteQueueSnapshot>(baseUrl, '/v1/queue', { token });
return fetchJson<DesktopRemoteQueueSnapshot>(baseUrl, '/v1/queue', { token, fingerprint: certificateFingerprint });
}
export interface DesktopRemoteSessionInfo {
deviceId: string;
scopes: ('control' | 'sync')[];
issuedAt: number;
rotatedAt: number;
rotateAfter: number;
rotateRequiredAt: number;
expiresAt: number;
rotationRequired: boolean;
usingPreviousCredential: boolean;
}
export interface DesktopRemoteRotatedCredentials {
controlToken: string;
syncToken: string;
issuedAt: number;
previousValidUntil: number;
rotateAfter: number;
}
export async function inspectDesktopRemoteSession(
baseUrl: string,
controlToken: string,
certificateFingerprint: string
): Promise<DesktopRemoteSessionInfo> {
return fetchJson<DesktopRemoteSessionInfo>(baseUrl, '/v1/session', {
token: controlToken,
fingerprint: certificateFingerprint,
});
}
export async function rotateDesktopRemoteCredentials(
baseUrl: string,
controlToken: string,
certificateFingerprint: string
): Promise<DesktopRemoteRotatedCredentials> {
return fetchJson<DesktopRemoteRotatedCredentials>(baseUrl, '/v1/session/rotate', {
method: 'POST',
token: controlToken,
fingerprint: certificateFingerprint,
body: {},
});
}
// ── Favorites/playlists LAN sync (protocolVersion >= 2) ──────────────────────
// Sync payloads can carry thousands of favorites/playlist entries, so both
// calls get generous timeouts compared to the 8 s control default.
export async function fetchDesktopSyncState(baseUrl: string, token: string): Promise<DesktopSyncState> {
return fetchJson<DesktopSyncState>(baseUrl, '/v1/sync/state', { token, timeoutMs: 30_000 });
export async function fetchDesktopSyncState(baseUrl: string, token: string, certificateFingerprint: string): Promise<DesktopSyncState> {
return fetchJson<DesktopSyncState>(baseUrl, '/v1/sync/state', { token, fingerprint: certificateFingerprint, timeoutMs: 30_000 });
}
export async function postDesktopSyncApply(
baseUrl: string,
token: string,
certificateFingerprint: string,
payload: DesktopSyncApplyPayload
): Promise<DesktopSyncApplyResult> {
return fetchJson<DesktopSyncApplyResult>(baseUrl, '/v1/sync/apply', {
method: 'POST',
token,
fingerprint: certificateFingerprint,
body: payload,
timeoutMs: 60_000,
});
@@ -288,11 +332,13 @@ export async function postDesktopSyncApply(
export async function postDesktopSyncConflicts(
baseUrl: string,
token: string,
certificateFingerprint: string,
payload: DesktopSyncConflictReportPayload
): Promise<void> {
await fetchJson<{ ok: true }>(baseUrl, '/v1/sync/conflicts', {
method: 'POST',
token,
fingerprint: certificateFingerprint,
body: payload,
});
}
@@ -307,103 +353,53 @@ export type DesktopRemoteSseHandlers = {
onError?: (error: unknown) => void;
};
function processSseChunk(
buffer: { value: string },
chunk: string,
onSnapshot: (snapshot: DesktopRemoteNowPlayingSnapshot) => void,
onQueue?: (queue: DesktopRemoteQueueSnapshot) => void,
onSyncRequest?: () => void
): void {
buffer.value += chunk.replace(/\r/g, '');
let boundary = buffer.value.indexOf('\n\n');
while (boundary !== -1) {
const raw = buffer.value.slice(0, boundary);
buffer.value = buffer.value.slice(boundary + 2);
let eventName = 'message';
const data: string[] = [];
for (const line of raw.split('\n')) {
if (!line || line.startsWith(':')) continue;
if (line.startsWith('event:')) {
eventName = line.slice(6).trim();
continue;
}
if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
}
if (eventName === 'now-playing' && data.length > 0) {
try {
onSnapshot(JSON.parse(data.join('\n')) as DesktopRemoteNowPlayingSnapshot);
} catch {
// Ignore a malformed event; polling/reconnect will correct the UI.
}
} else if (eventName === 'queue' && data.length > 0 && onQueue) {
try {
onQueue(JSON.parse(data.join('\n')) as DesktopRemoteQueueSnapshot);
} catch {
// Ignore a malformed event; the on-demand queue fetch will correct it.
}
} else if (eventName === 'sync-request' && onSyncRequest) {
onSyncRequest();
}
boundary = buffer.value.indexOf('\n\n');
}
}
export function startDesktopRemoteEventStream(
baseUrl: string,
token: string,
certificateFingerprint: string,
handlers: DesktopRemoteSseHandlers
): () => void {
const controller = new AbortController();
let closed = false;
let streamId: string | null = null;
const transport = AstraDesktopTransport;
if (!transport) {
handlers.onError?.(new Error('Secure Desktop Remote transport is unavailable on this device.'));
return () => {};
}
void (async () => {
try {
const response = await fetch(`${baseUrl}/v1/events`, {
headers: {
Accept: 'text/event-stream',
Authorization: `Bearer ${token}`,
},
cache: 'no-store',
signal: controller.signal,
});
if (response.status === 401) {
handlers.onUnauthorized();
return;
}
const body = response.body as unknown as {
getReader?: () => {
read: () => Promise<{ done: boolean; value?: Uint8Array }>;
};
} | null;
if (!response.ok || !body?.getReader) throw new Error(`SSE unavailable (${response.status})`);
const reader = body.getReader();
const decoder = new TextDecoder();
const buffer = { value: '' };
while (!closed) {
const next = await reader.read();
if (next.done) break;
if (next.value) {
processSseChunk(
buffer,
decoder.decode(next.value, { stream: true }),
handlers.onSnapshot,
handlers.onQueue,
handlers.onSyncRequest
);
}
}
processSseChunk(buffer, decoder.decode(), handlers.onSnapshot, handlers.onQueue, handlers.onSyncRequest);
if (!closed) handlers.onDisconnect();
} catch (error) {
if (closed || controller.signal.aborted) return;
handlers.onError?.(error);
const eventSubscription = transport.addListener('onDesktopTransportSse', (event) => {
if (closed || !streamId || event.streamId !== streamId) return;
if (event.event === 'now-playing') {
try { handlers.onSnapshot(JSON.parse(event.data) as DesktopRemoteNowPlayingSnapshot); } catch { /* ignore */ }
} else if (event.event === 'queue' && handlers.onQueue) {
try { handlers.onQueue(JSON.parse(event.data) as DesktopRemoteQueueSnapshot); } catch { /* ignore */ }
} else if (event.event === 'sync-request') {
handlers.onSyncRequest?.();
}
});
const closedSubscription = transport.addListener('onDesktopTransportClosed', (event) => {
if (closed || !streamId || event.streamId !== streamId) return;
if (event.unauthorized) handlers.onUnauthorized();
else if (event.message) {
handlers.onError?.(new Error(event.message));
handlers.onDisconnect();
}
})();
});
void transport.startEventStream(baseUrl, token, certificateFingerprint).then((id) => {
streamId = id;
if (closed) transport.stopEventStream(id);
}).catch((error) => {
if (closed) return;
handlers.onError?.(error);
handlers.onDisconnect();
});
return () => {
if (closed) return;
closed = true;
controller.abort();
eventSubscription.remove();
closedSubscription.remove();
if (streamId) transport.stopEventStream(streamId);
};
}
+82 -13
View File
@@ -1,25 +1,70 @@
import * as SecureStore from 'expo-secure-store';
import type { DesktopRemoteConnection } from '@/types/desktopRemote';
const CONNECTION_KEY = 'desktop_remote_connection_v1';
const TOKEN_KEY = 'desktop_remote_token_v1';
const CONNECTION_KEY = 'desktop_remote_connection_v3';
const CREDENTIALS_KEY = 'desktop_remote_credentials_v3';
const LEGACY_CONNECTION_KEY = 'desktop_remote_connection_v1';
const LEGACY_TOKEN_KEY = 'desktop_remote_token_v1';
const SECURITY_UPGRADE_NOTICE_KEY = 'desktop_remote_security_upgrade_notice_v3';
export interface DesktopRemoteCredentials {
controlToken: string;
syncToken: string;
issuedAt: number;
}
let legacyMigrationPromise: Promise<void> | null = null;
async function migrateLegacyPairing(): Promise<void> {
legacyMigrationPromise ??= (async () => {
const [legacyConnection, legacyToken] = await Promise.all([
SecureStore.getItemAsync(LEGACY_CONNECTION_KEY),
SecureStore.getItemAsync(LEGACY_TOKEN_KEY),
]);
if (!legacyConnection && !legacyToken) return;
let label = 'Astra Desktop';
try {
const parsed = legacyConnection ? JSON.parse(legacyConnection) as Record<string, unknown> : null;
if (parsed && typeof parsed.desktopName === 'string' && parsed.desktopName.trim()) label = parsed.desktopName.trim();
} catch {
// Only a non-secret label is retained.
}
await Promise.all([
SecureStore.deleteItemAsync(LEGACY_CONNECTION_KEY),
SecureStore.deleteItemAsync(LEGACY_TOKEN_KEY),
SecureStore.setItemAsync(SECURITY_UPGRADE_NOTICE_KEY, label),
]);
})();
await legacyMigrationPromise;
}
export async function getDesktopRemoteConnection(): Promise<DesktopRemoteConnection | null> {
await migrateLegacyPairing();
const raw = await SecureStore.getItemAsync(CONNECTION_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<DesktopRemoteConnection>;
if (!parsed || typeof parsed !== 'object') return null;
if (typeof parsed.id !== 'string' || typeof parsed.baseUrl !== 'string') return null;
if (
typeof parsed.id !== 'string' || typeof parsed.baseUrl !== 'string' ||
!parsed.baseUrl.startsWith('https://') || typeof parsed.certificateFingerprint !== 'string' ||
parsed.protocolVersion !== 3
) return null;
const scopes = Array.isArray(parsed.scopes)
? parsed.scopes.filter((scope): scope is 'control' | 'sync' => scope === 'control' || scope === 'sync')
: [];
if (!scopes.includes('control') || !scopes.includes('sync')) return null;
return {
id: parsed.id,
baseUrl: parsed.baseUrl,
certificateFingerprint: parsed.certificateFingerprint,
scopes,
credentialIssuedAt: typeof parsed.credentialIssuedAt === 'number' ? parsed.credentialIssuedAt : parsed.pairedAt ?? Date.now(),
credentialRotatedAt: typeof parsed.credentialRotatedAt === 'number' ? parsed.credentialRotatedAt : parsed.pairedAt ?? Date.now(),
securityUpgradeState: 'none',
endpointUuid: typeof parsed.endpointUuid === 'string' ? parsed.endpointUuid : null,
desktopName: typeof parsed.desktopName === 'string' ? parsed.desktopName : null,
protocolVersion:
typeof parsed.protocolVersion === 'number' && Number.isFinite(parsed.protocolVersion)
? parsed.protocolVersion
: 1,
protocolVersion: 3,
deviceId: typeof parsed.deviceId === 'string' ? parsed.deviceId : null,
pairedAt:
typeof parsed.pairedAt === 'number' && Number.isFinite(parsed.pairedAt)
@@ -39,18 +84,42 @@ export async function setDesktopRemoteConnection(connection: DesktopRemoteConnec
await SecureStore.setItemAsync(CONNECTION_KEY, JSON.stringify(connection));
}
export async function getDesktopRemoteToken(): Promise<string | null> {
const token = await SecureStore.getItemAsync(TOKEN_KEY);
return token && token.trim() ? token.trim() : null;
export async function getDesktopRemoteCredentials(): Promise<DesktopRemoteCredentials | null> {
const raw = await SecureStore.getItemAsync(CREDENTIALS_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<DesktopRemoteCredentials>;
if (!parsed.controlToken?.trim() || !parsed.syncToken?.trim() || !Number.isFinite(parsed.issuedAt)) return null;
return { controlToken: parsed.controlToken.trim(), syncToken: parsed.syncToken.trim(), issuedAt: parsed.issuedAt! };
} catch {
return null;
}
}
export async function setDesktopRemoteToken(token: string): Promise<void> {
await SecureStore.setItemAsync(TOKEN_KEY, token);
export async function setDesktopRemoteCredentials(credentials: DesktopRemoteCredentials): Promise<void> {
await SecureStore.setItemAsync(CREDENTIALS_KEY, JSON.stringify(credentials));
}
export async function getDesktopRemoteToken(): Promise<string | null> {
return (await getDesktopRemoteCredentials())?.controlToken ?? null;
}
export async function getDesktopRemoteSyncToken(): Promise<string | null> {
return (await getDesktopRemoteCredentials())?.syncToken ?? null;
}
export async function getDesktopRemoteSecurityUpgradeNotice(): Promise<string | null> {
await migrateLegacyPairing();
return SecureStore.getItemAsync(SECURITY_UPGRADE_NOTICE_KEY);
}
export async function clearDesktopRemoteSecurityUpgradeNotice(): Promise<void> {
await SecureStore.deleteItemAsync(SECURITY_UPGRADE_NOTICE_KEY);
}
export async function clearDesktopRemotePairing(): Promise<void> {
await Promise.all([
SecureStore.deleteItemAsync(CONNECTION_KEY),
SecureStore.deleteItemAsync(TOKEN_KEY),
SecureStore.deleteItemAsync(CREDENTIALS_KEY),
]);
}
+27 -12
View File
@@ -6,13 +6,16 @@ import {
parseDesktopRemotePairingInput,
} from './desktopRemotePairing.ts';
test('parses current PWA pairing URL format', () => {
assert.deepEqual(
const fingerprint = 'AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99';
test('rejects browser and legacy HTTP pairing links on the native client', () => {
assert.equal(
parseDesktopRemotePairingInput('https://192.168.1.20:38402/remote/#pair=abcDEF_1234567890'),
null
);
assert.equal(
parseDesktopRemotePairingInput('http://192.168.1.20:38402/remote/#pair=abcDEF_1234567890'),
{
baseUrl: 'http://192.168.1.20:38402',
ticket: 'abcDEF_1234567890',
}
null
);
});
@@ -21,21 +24,33 @@ test('parses native pairing links without accepting missing base URLs', () => {
parseDesktopRemotePairingInput(
'astra://desktop-remote/pair?baseUrl=http%3A%2F%2F10.0.0.8%3A38402&ticket=abcDEF_1234567890'
),
null
);
assert.deepEqual(
parseDesktopRemotePairingInput(
`astra://desktop-remote?baseUrl=https%3A%2F%2F10.0.0.8%3A38402&ticket=abcDEF_1234567890&endpointUuid=endpoint-1&fingerprint=${encodeURIComponent(fingerprint)}&protocolVersion=3`
),
{
baseUrl: 'http://10.0.0.8:38402',
baseUrl: 'https://10.0.0.8:38402',
ticket: 'abcDEF_1234567890',
endpointUuid: 'endpoint-1',
protocolVersion: 3,
certificateFingerprint: fingerprint,
}
);
assert.equal(parseDesktopRemotePairingInput('astra://desktop-remote/pair?ticket=abcDEF_1234567890'), null);
});
test('manual pairing requires a reachable http base URL and ticket-shaped code', () => {
assert.deepEqual(parseDesktopRemoteManualInput('http://desktop.local:38402/remote/', 'abcDEF_1234567890'), {
baseUrl: 'http://desktop.local:38402',
test('manual ticket parsing requires HTTPS, endpoint identity, and fingerprint', () => {
assert.deepEqual(parseDesktopRemoteManualInput('https://desktop.local:38402/remote/', 'abcDEF_1234567890', 'endpoint-1', fingerprint), {
baseUrl: 'https://desktop.local:38402',
ticket: 'abcDEF_1234567890',
endpointUuid: 'endpoint-1',
protocolVersion: 3,
certificateFingerprint: fingerprint,
});
assert.equal(parseDesktopRemoteManualInput('ftp://desktop.local', 'abcDEF_1234567890'), null);
assert.equal(parseDesktopRemoteManualInput('http://desktop.local:38402', 'short'), null);
assert.equal(parseDesktopRemoteManualInput('http://desktop.local:38402', 'abcDEF_1234567890', 'endpoint-1', fingerprint), null);
assert.equal(parseDesktopRemoteManualInput('https://desktop.local:38402', 'short', 'endpoint-1', fingerprint), null);
});
test('PIN pairing accepts only six digits with optional spacing', () => {
+37 -7
View File
@@ -8,13 +8,19 @@ function normalizeBaseUrl(value: string): string | null {
if (!trimmed) return null;
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
if (parsed.protocol !== 'https:') return null;
return parsed.origin;
} catch {
return null;
}
}
function normalizeFingerprint(value: string): string | null {
const compact = value.trim().toUpperCase().replace(/[^0-9A-F]/g, '');
if (!/^[0-9A-F]{64}$/.test(compact)) return null;
return compact.match(/.{2}/g)?.join(':') ?? null;
}
function extractPairFromUrl(url: URL): string {
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ''));
const hashTicket = hashParams.get('pair')?.trim();
@@ -31,22 +37,46 @@ export function parseDesktopRemotePairingInput(rawInput: string): DesktopRemoteP
if (parsed.protocol === 'astra:') {
const baseUrl = normalizeBaseUrl(parsed.searchParams.get('baseUrl') ?? '');
const ticket = parsed.searchParams.get('ticket')?.trim() ?? parsed.searchParams.get('pair')?.trim() ?? '';
return baseUrl && PAIRING_TICKET_PATTERN.test(ticket) ? { baseUrl, ticket } : null;
const endpointUuid = parsed.searchParams.get('endpointUuid')?.trim() ?? '';
const fingerprint = normalizeFingerprint(parsed.searchParams.get('fingerprint') ?? '');
const protocolVersion = Number(parsed.searchParams.get('protocolVersion'));
return baseUrl && endpointUuid && fingerprint && protocolVersion === 3 && PAIRING_TICKET_PATTERN.test(ticket)
? { baseUrl, ticket, endpointUuid, protocolVersion: 3, certificateFingerprint: fingerprint }
: null;
}
const ticket = extractPairFromUrl(parsed);
const baseUrl = normalizeBaseUrl(parsed.origin);
return baseUrl && PAIRING_TICKET_PATTERN.test(ticket) ? { baseUrl, ticket } : null;
const hashParams = new URLSearchParams(parsed.hash.replace(/^#/, ''));
const endpointUuid = hashParams.get('endpointUuid')?.trim() ?? parsed.searchParams.get('endpointUuid')?.trim() ?? '';
const fingerprint = normalizeFingerprint(hashParams.get('fingerprint') ?? parsed.searchParams.get('fingerprint') ?? '');
const protocolVersion = Number(hashParams.get('protocolVersion') ?? parsed.searchParams.get('protocolVersion'));
return baseUrl && endpointUuid && fingerprint && protocolVersion === 3 && PAIRING_TICKET_PATTERN.test(ticket)
? { baseUrl, ticket, endpointUuid, protocolVersion: 3, certificateFingerprint: fingerprint }
: null;
} catch {
return PAIRING_TICKET_PATTERN.test(input) ? { baseUrl: '', ticket: input } : null;
return null;
}
}
export function parseDesktopRemoteManualInput(baseUrl: string, ticket: string): DesktopRemotePairingInput | null {
export function parseDesktopRemoteManualInput(
baseUrl: string,
ticket: string,
endpointUuid: string,
certificateFingerprint: string
): DesktopRemotePairingInput | null {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
const normalizedTicket = ticket.trim();
if (!normalizedBaseUrl || !PAIRING_TICKET_PATTERN.test(normalizedTicket)) return null;
return { baseUrl: normalizedBaseUrl, ticket: normalizedTicket };
const normalizedEndpointUuid = endpointUuid.trim();
const normalizedFingerprint = normalizeFingerprint(certificateFingerprint);
if (!normalizedBaseUrl || !normalizedEndpointUuid || !normalizedFingerprint || !PAIRING_TICKET_PATTERN.test(normalizedTicket)) return null;
return {
baseUrl: normalizedBaseUrl,
ticket: normalizedTicket,
endpointUuid: normalizedEndpointUuid,
protocolVersion: 3,
certificateFingerprint: normalizedFingerprint,
};
}
export function normalizeDesktopRemotePinInput(pin: string): string | null {
+58
View File
@@ -0,0 +1,58 @@
import {
DesktopRemoteHttpError,
inspectDesktopRemoteSession,
rotateDesktopRemoteCredentials,
} from '@/services/desktopRemoteClient';
import {
getDesktopRemoteCredentials,
setDesktopRemoteConnection,
setDesktopRemoteCredentials,
type DesktopRemoteCredentials,
} from '@/services/desktopRemoteCredentials';
import type { DesktopRemoteConnection } from '@/types/desktopRemote';
export async function ensureDesktopRemoteCredentialsFresh(
connection: DesktopRemoteConnection,
suppliedCredentials?: DesktopRemoteCredentials
): Promise<{ connection: DesktopRemoteConnection; credentials: DesktopRemoteCredentials }> {
const credentials = suppliedCredentials ?? await getDesktopRemoteCredentials();
if (!credentials) throw new Error('Desktop credentials are unavailable. Pair again.');
let shouldRotate = false;
try {
const session = await inspectDesktopRemoteSession(
connection.baseUrl,
credentials.controlToken,
connection.certificateFingerprint
);
shouldRotate = session.usingPreviousCredential || Date.now() >= session.rotateAfter;
} catch (error) {
if (!(error instanceof DesktopRemoteHttpError) || error.status !== 401) throw error;
shouldRotate = true;
}
if (!shouldRotate) return { connection, credentials };
const rotated = await rotateDesktopRemoteCredentials(
connection.baseUrl,
credentials.controlToken,
connection.certificateFingerprint
);
if (!rotated.controlToken?.trim() || !rotated.syncToken?.trim()) {
throw new Error('Desktop returned incomplete rotated credentials.');
}
const nextCredentials: DesktopRemoteCredentials = {
controlToken: rotated.controlToken,
syncToken: rotated.syncToken,
issuedAt: rotated.issuedAt,
};
const nextConnection: DesktopRemoteConnection = {
...connection,
credentialIssuedAt: rotated.issuedAt,
credentialRotatedAt: rotated.issuedAt,
lastConnectedAt: Date.now(),
};
await Promise.all([
setDesktopRemoteConnection(nextConnection),
setDesktopRemoteCredentials(nextCredentials),
]);
return { connection: nextConnection, credentials: nextCredentials };
}
+20 -14
View File
@@ -62,10 +62,11 @@ import {
postDesktopSyncConflicts,
} from './desktopRemoteClient';
import {
getDesktopRemoteCredentials,
getDesktopRemoteConnection,
getDesktopRemoteToken,
setDesktopRemoteConnection,
getDesktopRemoteSyncToken,
} from './desktopRemoteCredentials';
import { ensureDesktopRemoteCredentialsFresh } from './desktopRemoteSession';
const CLOCK_SKEW_WARN_MS = 5 * 60_000;
@@ -160,10 +161,10 @@ export async function runDesktopSync(): Promise<DesktopSyncSummary> {
// Report remaining conflicts (best-effort — older desktops 404 here).
const connection = await getDesktopRemoteConnection();
const token = await getDesktopRemoteToken();
const token = await getDesktopRemoteSyncToken();
if (connection && token) {
try {
await postDesktopSyncConflicts(connection.baseUrl, token, {
await postDesktopSyncConflicts(connection.baseUrl, token, connection.certificateFingerprint, {
syncFormat: DESKTOP_SYNC_FORMAT,
conflicts: summary.conflicts.map((conflict) => ({
kind: conflict.kind,
@@ -194,22 +195,22 @@ async function runDesktopSyncOnce(): Promise<{
pendingResolutions: DesktopSyncPendingResolution[];
}> {
const startedAt = Date.now();
const connection = await getDesktopRemoteConnection();
const token = await getDesktopRemoteToken();
if (!connection || !token) {
let connection = await getDesktopRemoteConnection();
const credentials = await getDesktopRemoteCredentials();
if (!connection || !credentials) {
throw new Error('No paired desktop.');
}
const fresh = await ensureDesktopRemoteCredentialsFresh(connection, credentials);
connection = fresh.connection;
const token = fresh.credentials.syncToken;
// The stored protocolVersion predates any desktop upgrade — re-check live and
// persist the refreshed value before gating.
const identity = await fetchDesktopRemoteIdentity(connection.baseUrl);
const identity = await fetchDesktopRemoteIdentity(connection.baseUrl, connection.certificateFingerprint);
if (!identity) {
throw new Error('Desktop is unreachable.');
}
if (identity.protocolVersion !== connection.protocolVersion) {
await setDesktopRemoteConnection({ ...connection, protocolVersion: identity.protocolVersion });
}
if (identity.protocolVersion < DESKTOP_SYNC_MIN_PROTOCOL_VERSION) {
if (identity.protocolVersion !== DESKTOP_SYNC_MIN_PROTOCOL_VERSION || identity.endpointUuid !== connection.endpointUuid) {
throw new DesktopSyncUnsupportedError();
}
@@ -218,7 +219,7 @@ async function runDesktopSyncOnce(): Promise<{
const index = buildImportIndex(await getAllTracks(db));
await resolvePendingFavorites(db, index);
const local = await getLocalSyncState(db);
const remote = await fetchDesktopSyncState(connection.baseUrl, token);
const remote = await fetchDesktopSyncState(connection.baseUrl, token, connection.certificateFingerprint);
if (remote.syncFormat !== DESKTOP_SYNC_FORMAT) {
throw new DesktopSyncUnsupportedError();
}
@@ -556,7 +557,12 @@ async function runDesktopSyncOnce(): Promise<{
payload.playlistUpserts.length > 0 ||
payload.playlistDeletes.length > 0;
if (hasDiff) {
const result = await postDesktopSyncApply(connection.baseUrl, token, payload);
const result = await postDesktopSyncApply(
connection.baseUrl,
token,
connection.certificateFingerprint,
payload
);
summary.pushedToDesktop = true;
summary.favoritesAdded += result.favorites.added;
summary.favoritesPending += result.favorites.pending;
+27
View File
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
canStartDesktopSync,
decideDesktopSyncEnabled,
identityMatchesPinnedConnection,
} from './desktopSyncPolicy.ts';
test('legacy automatic-sync migration preserves explicit off and defaults to on', () => {
assert.equal(decideDesktopSyncEnabled(null, '0'), false);
assert.equal(decideDesktopSyncEnabled(null, '1'), true);
assert.equal(decideDesktopSyncEnabled(null, null), true);
assert.equal(decideDesktopSyncEnabled('0', '1'), false);
});
test('master switch gates manual, automatic, and follow-up starts', () => {
assert.equal(canStartDesktopSync(false, 'idle'), false);
assert.equal(canStartDesktopSync(false, 'error'), false);
assert.equal(canStartDesktopSync(true, 'syncing'), false);
assert.equal(canStartDesktopSync(true, 'idle'), true);
});
test('discovered address requires v3 identity and the paired endpoint UUID', () => {
assert.equal(identityMatchesPinnedConnection('endpoint-1', 3, 'endpoint-1'), true);
assert.equal(identityMatchesPinnedConnection('endpoint-1', 3, 'impostor'), false);
assert.equal(identityMatchesPinnedConnection('endpoint-1', 2, 'endpoint-1'), false);
});
+23
View File
@@ -0,0 +1,23 @@
export function decideDesktopSyncEnabled(
masterSetting: string | null,
legacyAutoSetting: string | null
): boolean {
if (masterSetting !== null) return masterSetting !== '0';
// Conservative migration: an explicit legacy 0 stays off; 1 or absence is on.
return legacyAutoSetting !== '0';
}
export function canStartDesktopSync(
enabled: boolean,
status: 'idle' | 'syncing' | 'error'
): boolean {
return enabled && status !== 'syncing';
}
export function identityMatchesPinnedConnection(
expectedEndpointUuid: string | null,
protocolVersion: number,
observedEndpointUuid: string | null
): boolean {
return protocolVersion === 3 && Boolean(expectedEndpointUuid) && observedEndpointUuid === expectedEndpointUuid;
}