From f4ed31aca47117d0345f914f9deed7fac6201cb1 Mon Sep 17 00:00:00 2001 From: Banna Date: Tue, 6 Oct 2026 00:21:54 -0400 Subject: [PATCH 1/2] ci: the Mac launch check fails if launching archdraw touched its keychain item A user who installed 0.1.0 was asked for their login password at first launch ("archdraw wants to use your confidential information stored in 'archdraw Safe Storage'"), before storing any AI key. The check after the packaged app's launch asserts that no 'archdraw Safe Storage' item exists on the runner. Agent: Banna --- .github/workflows/ci.yml | 9 +++++++++ .github/workflows/release.yml | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a1531f..b8a0f8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,15 @@ jobs: if: runner.os == 'macOS' working-directory: app run: ARCHDRAW_SMOKE=1 ARCHDRAW_NO_UPDATE=1 perl -e 'alarm 120; exec @ARGV' release/mac-arm64/archdraw.app/Contents/MacOS/archdraw + - name: launch leaves the keychain alone (macOS) + if: runner.os == 'macOS' + # the app opens its keychain item only when an AI key is saved or used: touching it at launch makes macOS ask + # every new user for their password before they have stored anything + run: | + if security find-generic-password -s "archdraw Safe Storage" >/dev/null 2>&1; then + echo "::error::launching archdraw created or opened the 'archdraw Safe Storage' keychain item" + exit 1 + fi - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: archdraw-${{ runner.os }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 119f0cc..a85981f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,6 +73,14 @@ jobs: if: runner.os == 'macOS' working-directory: app run: ARCHDRAW_SMOKE=1 ARCHDRAW_NO_UPDATE=1 perl -e 'alarm 120; exec @ARGV' release/mac-arm64/archdraw.app/Contents/MacOS/archdraw + - name: launch leaves the keychain alone (macOS) + if: runner.os == 'macOS' + # as in ci.yml: a keychain prompt at first launch, before any AI key is stored, is a bug + run: | + if security find-generic-password -s "archdraw Safe Storage" >/dev/null 2>&1; then + echo "::error::launching archdraw created or opened the 'archdraw Safe Storage' keychain item" + exit 1 + fi - name: package (Linux) if: runner.os == 'Linux' working-directory: app From 94dbfa8e276d539db743f41865779bbb190ce865 Mon Sep 17 00:00:00 2001 From: Banna Date: Tue, 6 Oct 2026 00:22:26 -0400 Subject: [PATCH 2/2] desktop: open the keychain only when an AI key is saved or read, not at launch On macOS any safeStorage call, isEncryptionAvailable() included, opens the 'archdraw Safe Storage' keychain item, and an ad hoc signed app gets a password prompt for it. SafeKeys called it in its constructor at launch, so every new Mac user was asked for their login password before storing anything. Only Linux can lack a keyring, so only Linux checks at launch (and only Linux shows the 'no system keychain' warning, whose text is about Linux keyrings). On a Mac whose keychain refused (Deny), saving a key now says so instead of storing it unprotected. Agent: Banna --- app/desktop/src/keys.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/app/desktop/src/keys.ts b/app/desktop/src/keys.ts index 52cf7e3..fbb2959 100644 --- a/app/desktop/src/keys.ts +++ b/app/desktop/src/keys.ts @@ -1,10 +1,14 @@ // The user's AI keys on the desktop: encrypted with the OS keychain through Electron's safeStorage (Keychain on macOS, // Secret Service / KWallet on Linux, DPAPI on Windows), in a 0600 file in userData. On a Linux desktop with no keyring, // safeStorage falls back to a hard-coded password ("basic_text"); `weak` says so, and the app tells the user. +// On macOS the keychain is opened only when a key is saved or a stored one is read: any safeStorage call, even +// isEncryptionAvailable(), makes macOS ask for the login password ("archdraw Safe Storage"), and a user who never +// stores a key should never see that. // Keys set in the environment (OPENAI_API_KEY, ...) are used when none is stored. import { readFileSync, renameSync, writeFileSync } from "node:fs" import { safeStorage } from "electron" +import { AppError } from "../../core/src/config.js" import { EnvKeys, type KeyStore } from "../../core/src/models.js" export class SafeKeys implements KeyStore { @@ -12,8 +16,9 @@ export class SafeKeys implements KeyStore { readonly weak: boolean constructor(private file: string) { + // macOS and Windows always have a keychain, so only Linux can be weak, and only there is it checked up front const backend = process.platform === "linux" ? (safeStorage as unknown as { getSelectedStorageBackend?: () => string }).getSelectedStorageBackend?.() : "os" - this.weak = !safeStorage.isEncryptionAvailable() || backend === "basic_text" + this.weak = process.platform === "linux" && (!safeStorage.isEncryptionAvailable() || backend === "basic_text") } private all(): Record { @@ -39,8 +44,11 @@ export class SafeKeys implements KeyStore { set(provider: string, key: string): void { const all = this.all() - // no keyring: the key is kept in this owner-only file with basic protection, and the app says so (`weak`) - all[provider] = safeStorage.isEncryptionAvailable() ? safeStorage.encryptString(key).toString("base64") : "plain:" + Buffer.from(key, "utf8").toString("base64") + const sealed = safeStorage.isEncryptionAvailable() + // a Mac or Windows keychain that refused (the user pressed Deny): say so rather than keep the key unprotected + if (!sealed && process.platform !== "linux") throw new AppError(409, "archdraw could not use your keychain, so the key was not saved. Save it again and choose Allow when your Mac asks.") + // no keyring on Linux: the key is kept in this owner-only file with basic protection, and the app says so (`weak`) + all[provider] = sealed ? safeStorage.encryptString(key).toString("base64") : "plain:" + Buffer.from(key, "utf8").toString("base64") const tmp = this.file + ".tmp" writeFileSync(tmp, JSON.stringify(all), { mode: 0o600 }) renameSync(tmp, this.file)