Skip to content

Commit b4d28e9

Browse files
feat(sync): approve new devices without recovery keys
Add an expiring, account-scoped enrollment mailbox with atomic device-cap replacement and authenticated API routes. Wire automatic desktop registration, trusted-device approval, recovery-key fallback, device management, generated clients, and coverage.
1 parent b7c1955 commit b4d28e9

242 files changed

Lines changed: 12181 additions & 1275 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/api/openapi.gen.json

Lines changed: 444 additions & 0 deletions
Large diffs are not rendered by default.

apps/desktop/src/auth/cloudsync-credentials.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export const DEVICE_LIMIT_ERROR_CODE = "sync_device_limit_reached";
4949
export const DEVICE_LIMIT_TOAST_ID = "cloudsync-device-limit";
5050

5151
export type CloudsyncCredentialBlock =
52+
| "approval_pending"
5253
| "device_limit"
5354
| "identity_mismatch"
5455
| "keychain_access"

apps/desktop/src/auth/cloudsync.test.ts

Lines changed: 163 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
execute,
1010
getCloudsyncStatus,
1111
getE2eeIdentityStatus,
12+
getOrCreateE2eeDeviceIdentity,
13+
importE2eeDeviceEnrollment,
1214
suspendCloudsync,
1315
suspendCloudsyncAfterAuthLoss,
1416
suspendCloudsyncForSignOut,
@@ -138,6 +140,28 @@ function replicaCredentialsResponse() {
138140
);
139141
}
140142

143+
function deviceEnrollmentResponse(status: "pending" | "sealed") {
144+
return new Response(
145+
JSON.stringify({
146+
requestId: "11111111-1111-4111-8111-111111111111",
147+
expiresAt: new Date(NOW.getTime() + 24 * 60 * 60 * 1000).toISOString(),
148+
status,
149+
package:
150+
status === "sealed"
151+
? {
152+
ephemeralPublicKey: "E".repeat(43),
153+
nonce: "N".repeat(32),
154+
ciphertext: "C".repeat(100),
155+
}
156+
: null,
157+
}),
158+
{
159+
status: 200,
160+
headers: { "Content-Type": "application/json" },
161+
},
162+
);
163+
}
164+
141165
function cloudsyncStatus(activityPaused = false) {
142166
return {
143167
cloudsync_enabled: true,
@@ -235,6 +259,17 @@ describe("CloudSync auth lifecycle", () => {
235259
keyId: E2EE_KEY_ID,
236260
memberPublicKey: E2EE_MEMBER_PUBLIC_KEY,
237261
});
262+
vi.mocked(getOrCreateE2eeDeviceIdentity).mockResolvedValue({
263+
publicKey: "A".repeat(43),
264+
});
265+
vi.mocked(importE2eeDeviceEnrollment).mockResolvedValue({
266+
keyId: E2EE_KEY_ID,
267+
});
268+
vi.mocked(miscCommands.getFingerprint).mockResolvedValue({
269+
status: "error",
270+
error: "unavailable",
271+
});
272+
vi.mocked(hostname).mockResolvedValue(null);
238273
vi.mocked(fsSyncCommands.deleteSessionFolder).mockResolvedValue({
239274
status: "ok",
240275
data: null,
@@ -280,25 +315,150 @@ describe("CloudSync auth lifecycle", () => {
280315
expect(getCloudsyncCredentialBlock()).toBeNull();
281316
});
282317

283-
test("does not request cloud credentials before E2EE recovery setup", async () => {
284-
const fetchMock = vi.fn();
318+
test("keeps first-device recovery setup separate from enrollment", async () => {
319+
const fetchMock = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(
320+
() =>
321+
Promise.resolve(
322+
new Response(
323+
JSON.stringify({
324+
error: {
325+
code: "e2ee_enrollment_requires_existing_key",
326+
message: "Set up encrypted sync on an existing device first",
327+
},
328+
}),
329+
{
330+
status: 409,
331+
headers: { "Content-Type": "application/json" },
332+
},
333+
),
334+
),
335+
);
285336
vi.stubGlobal("fetch", fetchMock);
286337
vi.mocked(getE2eeIdentityStatus).mockResolvedValue({
287338
configured: false,
288339
keyId: null,
289340
memberPublicKey: null,
290341
});
342+
vi.mocked(miscCommands.getFingerprint).mockResolvedValue({
343+
status: "ok",
344+
data: "fingerprint-1234",
345+
});
291346
vi.spyOn(console, "warn").mockImplementation(() => {});
292347

293348
await handleCloudsyncAuthChange("SIGNED_IN", session());
294349
await vi.advanceTimersByTimeAsync(60 * 60 * 1000);
295350

296-
expect(fetchMock).not.toHaveBeenCalled();
351+
expect(fetchMock).toHaveBeenCalledTimes(1);
352+
expect(fetchMock.mock.calls[0]?.[0].toString()).toBe(
353+
"https://api.test/sync/e2ee/device-enrollments",
354+
);
297355
expect(configureCloudsyncToken).not.toHaveBeenCalled();
298356
expect(suspendCloudsync).toHaveBeenCalledTimes(1);
299357
expect(getCloudsyncCredentialBlock()).toBe("setup_required");
300358
});
301359

360+
test("registers a keyless device and polls for approval", async () => {
361+
const fetchMock = vi.fn(() =>
362+
Promise.resolve(deviceEnrollmentResponse("pending")),
363+
);
364+
vi.stubGlobal("fetch", fetchMock);
365+
vi.mocked(getE2eeIdentityStatus).mockResolvedValue({
366+
configured: false,
367+
keyId: null,
368+
memberPublicKey: null,
369+
});
370+
vi.mocked(miscCommands.getFingerprint).mockResolvedValue({
371+
status: "ok",
372+
data: "fingerprint-1234",
373+
});
374+
375+
await handleCloudsyncAuthChange("SIGNED_IN", session());
376+
377+
expect(getCloudsyncCredentialBlock()).toBe("approval_pending");
378+
expect(fetchMock).toHaveBeenCalledTimes(1);
379+
expect(getOrCreateE2eeDeviceIdentity).toHaveBeenCalledWith("user-id");
380+
expect(configureCloudsyncToken).not.toHaveBeenCalled();
381+
382+
await vi.advanceTimersByTimeAsync(5 * 1000);
383+
384+
expect(fetchMock).toHaveBeenCalledTimes(2);
385+
expect(getCloudsyncCredentialBlock()).toBe("approval_pending");
386+
});
387+
388+
test("imports an approved package and continues credential exchange", async () => {
389+
const fetchMock = vi
390+
.fn<(input: RequestInfo | URL) => Promise<Response>>()
391+
.mockResolvedValueOnce(deviceEnrollmentResponse("sealed"))
392+
.mockResolvedValueOnce(new Response(null, { status: 204 }))
393+
.mockResolvedValueOnce(credentialsResponse());
394+
vi.stubGlobal("fetch", fetchMock);
395+
vi.mocked(getE2eeIdentityStatus)
396+
.mockResolvedValueOnce({
397+
configured: false,
398+
keyId: null,
399+
memberPublicKey: null,
400+
})
401+
.mockResolvedValueOnce({
402+
configured: true,
403+
keyId: E2EE_KEY_ID,
404+
memberPublicKey: E2EE_MEMBER_PUBLIC_KEY,
405+
});
406+
vi.mocked(miscCommands.getFingerprint).mockResolvedValue({
407+
status: "ok",
408+
data: "fingerprint-1234",
409+
});
410+
411+
await handleCloudsyncAuthChange("SIGNED_IN", session());
412+
413+
expect(importE2eeDeviceEnrollment).toHaveBeenCalledWith(
414+
"user-id",
415+
"11111111-1111-4111-8111-111111111111",
416+
{
417+
ephemeralPublicKey: "E".repeat(43),
418+
nonce: "N".repeat(32),
419+
ciphertext: "C".repeat(100),
420+
},
421+
);
422+
expect(fetchMock.mock.calls[1]?.[0].toString()).toContain("/consume");
423+
expect(configureCloudsyncToken).toHaveBeenCalledTimes(1);
424+
expect(getCloudsyncCredentialBlock()).toBeNull();
425+
});
426+
427+
test("blocks enrollment until a capped device is replaced", async () => {
428+
const fetchMock = vi.fn(() =>
429+
Promise.resolve(
430+
new Response(
431+
JSON.stringify({
432+
error: {
433+
code: "sync_device_limit_reached",
434+
message: "CloudSync device limit reached",
435+
},
436+
}),
437+
{
438+
status: 403,
439+
headers: { "Content-Type": "application/json" },
440+
},
441+
),
442+
),
443+
);
444+
vi.stubGlobal("fetch", fetchMock);
445+
vi.mocked(getE2eeIdentityStatus).mockResolvedValue({
446+
configured: false,
447+
keyId: null,
448+
memberPublicKey: null,
449+
});
450+
vi.mocked(miscCommands.getFingerprint).mockResolvedValue({
451+
status: "ok",
452+
data: "fingerprint-1234",
453+
});
454+
455+
await handleCloudsyncAuthChange("SIGNED_IN", session());
456+
457+
expect(getCloudsyncCredentialBlock()).toBe("device_limit");
458+
expect(sonnerToast.error).toHaveBeenCalledOnce();
459+
expect(configureCloudsyncToken).not.toHaveBeenCalled();
460+
});
461+
302462
test("surfaces macOS Keychain access failures separately", async () => {
303463
const fetchMock = vi.fn();
304464
vi.stubGlobal("fetch", fetchMock);

apps/desktop/src/auth/cloudsync.ts

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type { AuthChangeEvent, Session } from "@supabase/supabase-js";
44
import {
55
bindCloudsyncAccount,
66
getCloudsyncStatus,
7+
getOrCreateE2eeDeviceIdentity,
8+
importE2eeDeviceEnrollment,
79
isCloudsyncActivityDeferredError,
810
suspendCloudsync,
911
suspendCloudsyncAfterAuthLoss,
@@ -16,6 +18,7 @@ import {
1618
DEVICE_LIMIT_ERROR_CODE,
1719
DEVICE_LIMIT_TOAST_ID,
1820
getCloudsyncCredentialBlock,
21+
getDeviceIdentity,
1922
hasWorkspaceProjection,
2023
isCredentials,
2124
readE2eeIdentity,
@@ -31,6 +34,12 @@ import {
3134
import { flushCloudsyncSessionEvictions } from "./cloudsync-session-evictions";
3235
import { requestCloudsyncCredentials } from "./cloudsync-token-exchange";
3336
import { provisionMissingWorkspaceKeys } from "./cloudsync-workspace-keys";
37+
import {
38+
ENROLLMENT_REQUIRES_EXISTING_KEY_ERROR_CODE,
39+
SyncDeviceRequestError,
40+
consumeDeviceEnrollment,
41+
registerDeviceEnrollment,
42+
} from "./sync-devices";
3443

3544
import { resolveConfigValue } from "~/shared/config";
3645
import { isKeychainAccessError } from "~/shared/keychain";
@@ -44,6 +53,7 @@ export {
4453
const REFRESH_LEAD_MS = 2 * 60 * 1000;
4554
const RETRY_DELAY_MS = 60 * 1000;
4655
const ACTIVITY_RETRY_DELAY_MS = 5 * 1000;
56+
const ENROLLMENT_RETRY_DELAY_MS = 5 * 1000;
4757
const MIN_REFRESH_DELAY_MS = 1000;
4858
const EXCHANGE_TIMEOUT_MS = 25 * 1000;
4959
const EVICTION_RETRY_DELAY_MS = 30 * 1000;
@@ -600,6 +610,65 @@ function scheduleActivityStatusRetry(
600610
}, ACTIVITY_RETRY_DELAY_MS);
601611
}
602612

613+
async function enrollCurrentDevice(
614+
session: Session,
615+
activeGeneration: number,
616+
): Promise<"imported" | "pending"> {
617+
const controller = new AbortController();
618+
exchangeController = controller;
619+
const timeout = setTimeout(() => controller.abort(), EXCHANGE_TIMEOUT_MS);
620+
621+
try {
622+
const [device, enrollmentIdentity] = await Promise.all([
623+
getDeviceIdentity(),
624+
getOrCreateE2eeDeviceIdentity(session.user.id),
625+
]);
626+
if (
627+
controller.signal.aborted ||
628+
activeGeneration !== generation ||
629+
!device.fingerprint
630+
) {
631+
throw new Error("E2EE device enrollment was interrupted");
632+
}
633+
634+
const enrollment = await registerDeviceEnrollment({
635+
accessToken: session.access_token,
636+
publicKey: enrollmentIdentity.publicKey,
637+
fingerprint: device.fingerprint,
638+
deviceName: device.name,
639+
signal: controller.signal,
640+
});
641+
if (enrollment.status !== "sealed" || !enrollment.package) {
642+
return "pending";
643+
}
644+
645+
await importE2eeDeviceEnrollment(
646+
session.user.id,
647+
enrollment.requestId,
648+
enrollment.package,
649+
);
650+
try {
651+
await consumeDeviceEnrollment({
652+
accessToken: session.access_token,
653+
requestId: enrollment.requestId,
654+
publicKey: enrollmentIdentity.publicKey,
655+
fingerprint: device.fingerprint,
656+
signal: controller.signal,
657+
});
658+
} catch {
659+
console.warn(
660+
"[cloudsync] imported device enrollment acknowledgement failed; credential exchange will finalize it",
661+
);
662+
}
663+
return "imported";
664+
} finally {
665+
clearTimeout(timeout);
666+
if (exchangeController === controller) {
667+
exchangeController = null;
668+
}
669+
}
670+
}
671+
603672
async function activateCloudsync(
604673
session: Session,
605674
suspendBeforeExchange: boolean,
@@ -796,7 +865,7 @@ async function activateCloudsync(
796865
if (identityRead.status === "timed_out") {
797866
throw new Error("E2EE identity read timed out");
798867
}
799-
const identity = identityRead.value;
868+
let identity = identityRead.value;
800869
if (activeGeneration !== generation) {
801870
return "ok";
802871
}
@@ -820,6 +889,65 @@ async function activateCloudsync(
820889
}
821890
suspendedBeforeCredentialExchange = true;
822891
}
892+
if (!identity.configured) {
893+
let enrollment: Awaited<ReturnType<typeof enrollCurrentDevice>>;
894+
try {
895+
enrollment = await enrollCurrentDevice(session, activeGeneration);
896+
} catch (error) {
897+
if (activeGeneration !== generation) {
898+
return "ok";
899+
}
900+
if (
901+
error instanceof SyncDeviceRequestError &&
902+
error.code === ENROLLMENT_REQUIRES_EXISTING_KEY_ERROR_CODE
903+
) {
904+
setCredentialBlock("setup_required");
905+
await suspendCloudsyncAfterCredentialRejection(activeGeneration);
906+
console.warn(
907+
"[cloudsync] first-device E2EE recovery key setup is required; sync remains disabled",
908+
);
909+
return "ok";
910+
}
911+
if (error instanceof SyncDeviceRequestError && error.status === 403) {
912+
const deviceLimit = error.code === DEVICE_LIMIT_ERROR_CODE;
913+
setCredentialBlock(deviceLimit ? "device_limit" : "not_entitled");
914+
await suspendCloudsyncAfterCredentialRejection(activeGeneration);
915+
if (deviceLimit) {
916+
sonnerToast.error(
917+
t`Cloud sync is limited to 5 devices. Replace or remove another device to sync here.`,
918+
{ id: DEVICE_LIMIT_TOAST_ID },
919+
);
920+
}
921+
return "ok";
922+
}
923+
throw error;
924+
}
925+
if (activeGeneration !== generation) {
926+
return "ok";
927+
}
928+
if (enrollment === "pending") {
929+
setCredentialBlock("approval_pending");
930+
await suspendCloudsyncAfterCredentialRejection(activeGeneration);
931+
if (activeGeneration === generation) {
932+
scheduleExchange(
933+
session,
934+
activeGeneration,
935+
ENROLLMENT_RETRY_DELAY_MS,
936+
onAccountMismatch,
937+
);
938+
}
939+
return "ok";
940+
}
941+
942+
const importedIdentity = await settleCloudsyncOperationWithin(
943+
readE2eeIdentity(session.user.id),
944+
EXCHANGE_TIMEOUT_MS,
945+
);
946+
if (importedIdentity.status === "timed_out") {
947+
throw new Error("Imported E2EE identity read timed out");
948+
}
949+
identity = importedIdentity.value;
950+
}
823951
if (
824952
!identity.configured ||
825953
!identity.keyId ||

0 commit comments

Comments
 (0)