Skip to content

Commit 159a7a9

Browse files
authored
fix: persist auth state in electron storage (#182)
1 parent 0ac44bb commit 159a7a9

File tree

7 files changed

+132
-3
lines changed

7 files changed

+132
-3
lines changed

apps/array/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@
125125
"file-icon": "^6.0.0",
126126
"idb-keyval": "^6.2.2",
127127
"node-addon-api": "^8.5.0",
128+
"node-machine-id": "^1.1.12",
128129
"node-pty": "1.1.0-beta39",
129130
"posthog-js": "^1.283.0",
130131
"posthog-node": "^4.18.0",

apps/array/src/main/preload.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ contextBridge.exposeInMainWorld("electronAPI", {
4242
ipcRenderer.invoke("retrieve-api-key", encryptedKey),
4343
fetchS3Logs: (logUrl: string): Promise<AgentEvent[]> =>
4444
ipcRenderer.invoke("fetch-s3-logs", logUrl),
45+
rendererStore: {
46+
getItem: (key: string): Promise<string | null> =>
47+
ipcRenderer.invoke("renderer-store:get", key),
48+
setItem: (key: string, value: string): Promise<void> =>
49+
ipcRenderer.invoke("renderer-store:set", key, value),
50+
removeItem: (key: string): Promise<void> =>
51+
ipcRenderer.invoke("renderer-store:remove", key),
52+
},
4553
// OAuth API
4654
oauthStartFlow: (
4755
region: CloudRegion,

apps/array/src/main/services/store.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,71 @@
1+
import crypto from "node:crypto";
2+
import os from "node:os";
13
import path from "node:path";
2-
import { app } from "electron";
4+
import { app, ipcMain } from "electron";
35
import Store from "electron-store";
6+
import { machineIdSync } from "node-machine-id";
47
import type {
58
RegisteredFolder,
69
TaskFolderAssociation,
710
} from "../../shared/types";
811
import { deleteWorktreeIfExists } from "./worktreeUtils";
912

13+
// Key derived from hardware UUID - data only decryptable on this machine
14+
// No keychain prompts, prevents token theft via cloud sync/backups
15+
const APP_SALT = "array-v1";
16+
const ENCRYPTION_VERSION = 1;
17+
18+
function getMachineKey(): Buffer {
19+
const machineId = machineIdSync();
20+
const identifier = [machineId, os.platform(), os.arch()].join("|");
21+
return crypto.scryptSync(identifier, APP_SALT, 32);
22+
}
23+
24+
function encrypt(plaintext: string): string {
25+
const key = getMachineKey();
26+
const iv = crypto.randomBytes(16);
27+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
28+
29+
const encrypted = Buffer.concat([
30+
cipher.update(plaintext, "utf8"),
31+
cipher.final(),
32+
]);
33+
const authTag = cipher.getAuthTag();
34+
35+
return JSON.stringify({
36+
v: ENCRYPTION_VERSION,
37+
iv: iv.toString("base64"),
38+
data: encrypted.toString("base64"),
39+
tag: authTag.toString("base64"),
40+
});
41+
}
42+
43+
function decrypt(encryptedJson: string): string | null {
44+
try {
45+
const { iv, data, tag } = JSON.parse(encryptedJson);
46+
const key = getMachineKey();
47+
const decipher = crypto.createDecipheriv(
48+
"aes-256-gcm",
49+
key,
50+
Buffer.from(iv, "base64"),
51+
);
52+
decipher.setAuthTag(Buffer.from(tag, "base64"));
53+
54+
return decipher.update(data, "base64", "utf8") + decipher.final("utf8");
55+
} catch {
56+
return null;
57+
}
58+
}
59+
1060
interface FoldersSchema {
1161
folders: RegisteredFolder[];
1262
taskAssociations: TaskFolderAssociation[];
1363
}
1464

65+
interface RendererStoreSchema {
66+
[key: string]: string;
67+
}
68+
1569
const schema = {
1670
folders: {
1771
type: "array" as const,
@@ -84,4 +138,30 @@ export async function clearAllStoreData(): Promise<void> {
84138
}
85139

86140
foldersStore.clear();
141+
rendererStore.clear();
87142
}
143+
144+
export const rendererStore = new Store<RendererStoreSchema>({
145+
name: "renderer-storage",
146+
cwd: getStorePath(),
147+
});
148+
149+
// IPC handlers for renderer storage with machine-key encryption
150+
ipcMain.handle("renderer-store:get", (_event, key: string): string | null => {
151+
if (!rendererStore.has(key)) {
152+
return null;
153+
}
154+
const encrypted = rendererStore.get(key) as string;
155+
return decrypt(encrypted);
156+
});
157+
158+
ipcMain.handle(
159+
"renderer-store:set",
160+
(_event, key: string, value: string): void => {
161+
rendererStore.set(key, encrypt(value));
162+
},
163+
);
164+
165+
ipcMain.handle("renderer-store:remove", (_event, key: string): void => {
166+
rendererStore.delete(key);
167+
});

apps/array/src/renderer/features/auth/stores/authStore.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { PostHogAPIClient } from "@api/posthogClient";
22
import { identifyUser, resetUser, track } from "@renderer/lib/analytics";
3+
import { electronStorage } from "@renderer/lib/electronStorage";
34
import { logger } from "@renderer/lib/logger";
45
import { queryClient } from "@renderer/lib/queryClient";
56
import type { CloudRegion } from "@shared/types/oauth";
@@ -238,6 +239,13 @@ export const useAuthStore = create<AuthState>()(
238239
},
239240

240241
initializeOAuth: async () => {
242+
// Wait for zustand hydration from async storage
243+
if (!useAuthStore.persist.hasHydrated()) {
244+
await new Promise<void>((resolve) => {
245+
useAuthStore.persist.onFinishHydration(() => resolve());
246+
});
247+
}
248+
241249
const state = get();
242250

243251
if (state.storedTokens) {
@@ -376,7 +384,8 @@ export const useAuthStore = create<AuthState>()(
376384
},
377385
}),
378386
{
379-
name: "mission-control-auth",
387+
name: "array-auth",
388+
storage: electronStorage,
380389
partialize: (state) => ({
381390
cloudRegion: state.cloudRegion,
382391
storedTokens: state.storedTokens,
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { createJSONStorage, type StateStorage } from "zustand/middleware";
2+
3+
/**
4+
* Raw storage adapter that uses Electron IPC to persist state.
5+
*/
6+
const electronStorageRaw: StateStorage = {
7+
getItem: async (name: string): Promise<string | null> => {
8+
return window.electronAPI.rendererStore.getItem(name);
9+
},
10+
setItem: async (name: string, value: string): Promise<void> => {
11+
await window.electronAPI.rendererStore.setItem(name, value);
12+
},
13+
removeItem: async (name: string): Promise<void> => {
14+
await window.electronAPI.rendererStore.removeItem(name);
15+
},
16+
};
17+
18+
export const electronStorage = createJSONStorage(() => electronStorageRaw);

apps/array/src/renderer/types/electron.d.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import type { AgentEvent } from "@posthog/agent";
21
import type {
32
ExternalAppContextMenuResult,
43
FolderContextMenuResult,
54
SplitContextMenuResult,
65
TabContextMenuResult,
76
TaskContextMenuResult,
87
} from "@main/services/contextMenu.types";
8+
import type { AgentEvent } from "@posthog/agent";
99
import type {
1010
ChangedFile,
1111
DetectedApplication,
@@ -22,6 +22,11 @@ declare global {
2222
storeApiKey: (apiKey: string) => Promise<string>;
2323
retrieveApiKey: (encryptedKey: string) => Promise<string | null>;
2424
fetchS3Logs: (logUrl: string) => Promise<AgentEvent[]>;
25+
rendererStore: {
26+
getItem: (key: string) => Promise<string | null>;
27+
setItem: (key: string, value: string) => Promise<void>;
28+
removeItem: (key: string) => Promise<void>;
29+
};
2530
// OAuth API
2631
oauthStartFlow: (region: CloudRegion) => Promise<{
2732
success: boolean;

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)