-
Notifications
You must be signed in to change notification settings - Fork 931
feat(desktop): replace keychain storage with encrypted file storage #366
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| import { execFileSync } from "node:child_process"; | ||
| import { | ||
| createCipheriv, | ||
| createDecipheriv, | ||
| randomBytes, | ||
| scryptSync, | ||
| } from "node:crypto"; | ||
| import { readFileSync } from "node:fs"; | ||
| import { homedir, hostname, platform } from "node:os"; | ||
|
|
||
| const ALGORITHM = "aes-256-gcm"; | ||
| const KEY_LENGTH = 32; | ||
| const SALT_LENGTH = 16; | ||
| const IV_LENGTH = 12; | ||
| const AUTH_TAG_LENGTH = 16; | ||
|
|
||
| /** | ||
| * Gets a stable machine identifier for key derivation. | ||
| * This provides "good enough" protection for local credential storage | ||
| * without requiring OS keychain access. | ||
| */ | ||
| function getMachineId(): string { | ||
| try { | ||
| const os = platform(); | ||
|
|
||
| if (os === "darwin") { | ||
| // macOS: Use IOPlatformUUID (hardware UUID) | ||
| const output = execFileSync( | ||
| "ioreg", | ||
| ["-rd1", "-c", "IOPlatformExpertDevice"], | ||
| { encoding: "utf8" }, | ||
| ); | ||
| const match = output.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/); | ||
| if (match?.[1]) return match[1]; | ||
| } else if (os === "linux") { | ||
| // Linux: Use machine-id | ||
| try { | ||
| return readFileSync("/etc/machine-id", "utf8").trim(); | ||
| } catch { | ||
| return readFileSync("/var/lib/dbus/machine-id", "utf8").trim(); | ||
| } | ||
| } else if (os === "win32") { | ||
| // Windows: Use MachineGuid from registry | ||
| const output = execFileSync( | ||
| "reg", | ||
| [ | ||
| "query", | ||
| "HKLM\\SOFTWARE\\Microsoft\\Cryptography", | ||
| "/v", | ||
| "MachineGuid", | ||
| ], | ||
| { encoding: "utf8" }, | ||
| ); | ||
| const match = output.match(/MachineGuid\s+REG_SZ\s+(\S+)/); | ||
| if (match?.[1]) return match[1]; | ||
| } | ||
| } catch { | ||
| // Fallback if platform-specific method fails | ||
| } | ||
|
|
||
| // Fallback: Use a combination of stable system properties | ||
| // This is less secure but ensures the app still works | ||
| return `${hostname()}-${homedir()}-superset-fallback`; | ||
|
Comment on lines
+61
to
+63
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Major: Fallback machine ID is weak and causes data loss on hostname/homedir changes. The fallback
Consider:
// Fallback: Use a combination of stable system properties
- // This is less secure but ensures the app still works
- return `${hostname()}-${homedir()}-superset-fallback`;
+ // Generate and persist a stable random ID on first run
+ // (implementation would check for ~/.superset/machine-id and create if missing)
+ throw new Error(
+ "Unable to determine machine identifier. Platform-specific commands failed."
+ );
|
||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * Derives an encryption key from the machine ID and a salt. | ||
| */ | ||
| function deriveKey(salt: Buffer): Buffer { | ||
| const machineId = getMachineId(); | ||
| return scryptSync(machineId, salt, KEY_LENGTH); | ||
| } | ||
|
|
||
| /** | ||
| * Encrypts a string using AES-256-GCM with a machine-derived key. | ||
| * Returns: salt (16) + iv (12) + authTag (16) + ciphertext | ||
| */ | ||
| export function encrypt(plaintext: string): Buffer { | ||
| const salt = randomBytes(SALT_LENGTH); | ||
| const key = deriveKey(salt); | ||
| const iv = randomBytes(IV_LENGTH); | ||
|
|
||
| const cipher = createCipheriv(ALGORITHM, key, iv); | ||
| const encrypted = Buffer.concat([ | ||
| cipher.update(plaintext, "utf8"), | ||
| cipher.final(), | ||
| ]); | ||
| const authTag = cipher.getAuthTag(); | ||
|
|
||
| // Combine all components: salt + iv + authTag + ciphertext | ||
| return Buffer.concat([salt, iv, authTag, encrypted]); | ||
| } | ||
|
|
||
| const MIN_ENCRYPTED_LENGTH = SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH + 1; | ||
|
|
||
| /** | ||
| * Decrypts data encrypted with the encrypt function. | ||
| */ | ||
| export function decrypt(data: Buffer): string { | ||
| if (data.length < MIN_ENCRYPTED_LENGTH) { | ||
| throw new Error("Encrypted data too short"); | ||
| } | ||
|
|
||
| // Extract components | ||
| const salt = data.subarray(0, SALT_LENGTH); | ||
| const iv = data.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH); | ||
| const authTag = data.subarray( | ||
| SALT_LENGTH + IV_LENGTH, | ||
| SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH, | ||
| ); | ||
| const ciphertext = data.subarray(SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH); | ||
|
|
||
| const key = deriveKey(salt); | ||
| const decipher = createDecipheriv(ALGORITHM, key, iv); | ||
| decipher.setAuthTag(authTag); | ||
|
|
||
| return Buffer.concat([ | ||
| decipher.update(ciphertext), | ||
| decipher.final(), | ||
| ]).toString("utf8"); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,14 +1,15 @@ | ||||||||||||||||||||||||||||||||||||||||||||
| import fs from "node:fs/promises"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { join } from "node:path"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { safeStorage } from "electron"; | ||||||||||||||||||||||||||||||||||||||||||||
| import type { AuthSession } from "shared/auth"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { SUPERSET_HOME_DIR } from "../app-environment"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { decrypt, encrypt } from "./crypto-storage"; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| const SESSION_FILE_NAME = "auth-session.enc"; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||
| * Securely stores authentication session using Electron's safeStorage API | ||||||||||||||||||||||||||||||||||||||||||||
| * Session data is encrypted at rest using the OS keychain | ||||||||||||||||||||||||||||||||||||||||||||
| * Securely stores authentication session using machine-derived encryption. | ||||||||||||||||||||||||||||||||||||||||||||
| * Session data is encrypted at rest using AES-256-GCM with a key derived | ||||||||||||||||||||||||||||||||||||||||||||
| * from the machine's hardware identifier. | ||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
9
to
13
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Docstring overstates security guarantees (“Securely stores…”) 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||
| class TokenStorage { | ||||||||||||||||||||||||||||||||||||||||||||
| private readonly filePath: string; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -18,25 +19,14 @@ class TokenStorage { | |||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| async save(session: AuthSession): Promise<void> { | ||||||||||||||||||||||||||||||||||||||||||||
| if (!safeStorage.isEncryptionAvailable()) { | ||||||||||||||||||||||||||||||||||||||||||||
| console.warn( | ||||||||||||||||||||||||||||||||||||||||||||
| "[auth] Secure storage not available, session will not be persisted", | ||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| const encrypted = safeStorage.encryptString(JSON.stringify(session)); | ||||||||||||||||||||||||||||||||||||||||||||
| const encrypted = encrypt(JSON.stringify(session)); | ||||||||||||||||||||||||||||||||||||||||||||
| await fs.writeFile(this.filePath, encrypted); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
21
to
24
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Harden async save(session: AuthSession): Promise<void> {
+ await fs.mkdir(SUPERSET_HOME_DIR, { recursive: true });
const encrypted = encrypt(JSON.stringify(session));
- await fs.writeFile(this.filePath, encrypted);
+ const tmpPath = `${this.filePath}.tmp`;
+ await fs.writeFile(tmpPath, encrypted, { mode: 0o600 });
+ // Best-effort atomic replace (Windows rename doesn’t overwrite)
+ await fs.rm(this.filePath, { force: true }).catch(() => {});
+ await fs.rename(tmpPath, this.filePath);
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| async load(): Promise<AuthSession | null> { | ||||||||||||||||||||||||||||||||||||||||||||
| if (!safeStorage.isEncryptionAvailable()) { | ||||||||||||||||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||
| const encrypted = await fs.readFile(this.filePath); | ||||||||||||||||||||||||||||||||||||||||||||
| const decrypted = safeStorage.decryptString(encrypted); | ||||||||||||||||||||||||||||||||||||||||||||
| const decrypted = decrypt(encrypted); | ||||||||||||||||||||||||||||||||||||||||||||
| return JSON.parse(decrypted) as AuthSession; | ||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||
| // File doesn't exist or can't be decrypted | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Critical: Still using blocking sync APIs in Electron main process despite previous review.
The previous review flagged
execSyncblocking issues, but the code still uses synchronous APIs (execFileSync,readFileSync,scryptSync). In particular:scryptSyncis CPU-intensive key derivation that will freeze the UI for 100ms+execFileSyncblocks during subprocess executionreadFileSyncblocks during disk I/OThese will be called on every encrypt/decrypt operation, causing noticeable UI freezes.
Migrate to async APIs:
Then update
getMachineId(),deriveKey(),encrypt(), anddecrypt()to be async and return Promises. This is essential for Electron main process responsiveness.Based on learnings, Electron IPC should use tRPC, so ensure the consumers of this module (token-storage.ts) expose async tRPC procedures.