Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions app/desktop/src/keys.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
// 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 {
private env = new EnvKeys()
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<string, string> {
Expand All @@ -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)
Expand Down
Loading