-
Notifications
You must be signed in to change notification settings - Fork 10
fix(store): bridge credentials I/O to macOS keychain #8
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
base: main
Are you sure you want to change the base?
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 |
|---|---|---|
| @@ -1,6 +1,10 @@ | ||
| const fs = require('fs'); | ||
| const os = require('os'); | ||
| const path = require('path'); | ||
| const { execFileSync } = require('child_process'); | ||
|
|
||
| const KEYCHAIN_SERVICE = 'Claude Code-credentials'; | ||
| const isDarwin = process.platform === 'darwin'; | ||
|
|
||
| function getDefaultConfigPath() { | ||
| return path.join(os.homedir(), '.claude.json'); | ||
|
|
@@ -36,6 +40,75 @@ function writeJson(filePath, value) { | |
| fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); | ||
| } | ||
|
|
||
| function credentialsUseKeychain(credentialsPath) { | ||
| return isDarwin && !fs.existsSync(credentialsPath); | ||
| } | ||
|
|
||
| function keychainAccount() { | ||
| try { | ||
| const out = execFileSync('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE], { encoding: 'utf8' }); | ||
| const match = out.match(/"acct"<blob>="([^"]*)"/); | ||
| return match ? match[1] : null; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| function readKeychainCredentials() { | ||
| try { | ||
| const raw = execFileSync('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w'], { encoding: 'utf8' }); | ||
| return JSON.parse(raw.trim()); | ||
| } catch (err) { | ||
| throw new Error(`Failed to read credentials from macOS keychain (service "${KEYCHAIN_SERVICE}"): ${err.message}`); | ||
| } | ||
| } | ||
|
|
||
| function writeKeychainCredentials(value) { | ||
| const account = keychainAccount() || os.userInfo().username || 'Claude Code'; | ||
| execFileSync('security', [ | ||
| 'add-generic-password', '-U', | ||
| '-s', KEYCHAIN_SERVICE, | ||
| '-a', account, | ||
| '-w', JSON.stringify(value), | ||
| ]); | ||
| } | ||
|
|
||
| function backupKeychainCredentials(backupDir) { | ||
| let value; | ||
| try { | ||
| value = readKeychainCredentials(); | ||
| } catch { | ||
| return; | ||
| } | ||
|
Comment on lines
+76
to
+82
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. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Stop the write when Keychain backup fails. Line 80 suppresses malformed JSON, denied Keychain access, and command failures. Skip backup only when the Keychain item is confirmed absent. Propagate every other read failure. 🤖 Prompt for AI Agents |
||
| ensureDir(backupDir); | ||
| const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-'); | ||
| fs.writeFileSync(path.join(backupDir, `credentials-keychain.${timestamp}.bak`), `${JSON.stringify(value, null, 2)}\n`, 'utf8'); | ||
| const backups = fs.readdirSync(backupDir) | ||
| .filter((name) => name.startsWith('credentials-keychain.') && name.endsWith('.bak')) | ||
| .sort() | ||
| .reverse(); | ||
| for (const stale of backups.slice(3)) { | ||
| fs.rmSync(path.join(backupDir, stale), { force: true }); | ||
| } | ||
| } | ||
|
|
||
| function readCredentials(credentialsPath) { | ||
| if (credentialsUseKeychain(credentialsPath)) { | ||
| return readKeychainCredentials(); | ||
| } | ||
| return readJson(credentialsPath); | ||
| } | ||
|
|
||
| function writeCredentials(credentialsPath, value, backupDir) { | ||
| if (credentialsUseKeychain(credentialsPath)) { | ||
| backupKeychainCredentials(backupDir); | ||
| writeKeychainCredentials(value); | ||
| return; | ||
| } | ||
| backupFile(credentialsPath, backupDir); | ||
| writeJson(credentialsPath, value); | ||
| } | ||
|
|
||
| function backupFile(filePath, backupDir) { | ||
| if (!fs.existsSync(filePath)) return; | ||
| ensureDir(backupDir); | ||
|
|
@@ -59,9 +132,8 @@ function deepCopy(value) { | |
|
|
||
| function writeLiveState(config, credentials, options) { | ||
| backupFile(options.configPath, options.backupDir); | ||
| backupFile(options.credentialsPath, options.backupDir); | ||
| writeJson(options.configPath, config); | ||
| writeJson(options.credentialsPath, credentials); | ||
| writeCredentials(options.credentialsPath, credentials, options.backupDir); | ||
| } | ||
|
|
||
| function writeStore(store, options) { | ||
|
|
@@ -79,6 +151,8 @@ module.exports = { | |
| readJsonIfExists, | ||
| writeJson, | ||
| backupFile, | ||
| readCredentials, | ||
| writeCredentials, | ||
| deepCopy, | ||
| writeLiveState, | ||
| writeStore, | ||
|
|
||
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.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: Leuconoe/ClaudeCodeMultiAccounts
Length of output: 231
🏁 Script executed:
Repository: Leuconoe/ClaudeCodeMultiAccounts
Length of output: 11035
🌐 Web query:
macOS security find-generic-password output acct blob stdout stderr💡 Result:
To retrieve a password from the macOS keychain using the security command-line tool, you primarily use the find-generic-password command [1][2]. Retrieving the Password To output only the password string to standard output (stdout), use the -w flag [1][2]: security find-generic-password -s "YourServiceName" -a "YourAccountName" -w Handling macOS 26 (Tahoe) Hex Encoding As of macOS 26 (Tahoe), the output format of security find-generic-password -w has changed [3]. If the stored password contains binary data or non-ASCII characters, it is now returned as a HEX-encoded string [3]. If you encounter this hex-encoded output in scripts, you must decode it to retrieve the original data [3]. You can use xxd to perform this conversion [3]: # Decode hex output back to binary security find-generic-password -s "YourServiceName" -w | xxd -r -p Additional Options -g: When used instead of or in addition to -w, this flag requests that the password be displayed [1]. Historically, -g outputs metadata and the password in a structured text format, often triggering a GUI prompt to authorize access [4][5]. -a (account): Specifies the account name associated with the generic password [1]. -s (service): Specifies the service name associated with the generic password [1]. Important Considerations - Access Control: The first time the security tool attempts to access a specific keychain item, you will be prompted to grant it permission [2]. You can grant persistent access via the prompt or by adding -T /usr/bin/security when initially creating the item [2]. - Duplicate Entries: The security command returns the first entry it finds that matches the specified -s (service) and -a (account) attributes [6]. If multiple entries exist for the same service but different accounts, or if duplicates have been created, querying by service alone may lead to unpredictable results [6]. - Security Note: Be cautious when storing retrieved passwords in environment variables, as they may be visible to other processes via tools like ps -E [7].
Citations:
🌐 Web query:
Node.js execFileSync return value stdout stderr documentation💡 Result:
The execFileSync method in Node.js returns the stdout from the executed command as a Buffer or string [1][2][3]. Key behavioral details include: Return Value: When successful, it returns the stdout of the process [1][2]. The type of the return value depends on the encoding option provided; if an encoding (e.g., 'utf8') is specified, it returns a string, otherwise it returns a Buffer [4][1]. Handling Stderr and Errors: 1. Default Behavior: By default, stderr from the child process is piped to the parent process's stderr and is not captured in the return value of execFileSync [4][5]. 2. Capturing Stderr: To capture stderr, you must configure the stdio option (e.g., set stdio: 'pipe') [4][3]. 3. Non-Zero Exit Codes: If the child process exits with a non-zero exit code or times out, execFileSync throws an Error [4][1]. This error object contains the full result of the process execution, including the stdout and stderr properties [4][3]. You can access these by catching the error: const { execFileSync } = require('node:child_process'); try { const stdout = execFileSync('my-script.sh', { stdio: 'pipe', encoding: 'utf8' }); } catch (err) { // If the process fails, access stdout/stderr from the error object const { stdout, stderr } = err; console.error({ stdout, stderr }); }
Citations:
🏁 Script executed:
Repository: Leuconoe/ClaudeCodeMultiAccounts
Length of output: 8063
🌐 Web query:
"acct\"<blob>" "security find-generic-password"💡 Result:
The string acct"= appears in the output of the macOS security command when querying keychain items, specifically when using the -g (dump) flag [1][2]. It is a formatted representation of the account attribute associated with a generic password item in a keychain [3]. When you run security find-generic-password with the -g (or --dump) option, the tool outputs detailed attributes of the found keychain item, often including internal metadata tags formatted like "acct"= [1][4]. Because this output is not designed for direct machine parsing, many scripts use tools like sed, awk, or grep to isolate the account name or password by targeting this specific string pattern [1][5][6]. For example, a common approach to extract the account name from such output involves: 1. Running security find-generic-password -g -s [service_name] to retrieve the full item details [7]. 2. Piping the output to grep to find the line containing acct"= [1]. 3. Using a stream editor like sed or cut to strip away the surrounding metadata and extract only the account value [1][5][6]. Note that this output format is specific to legacy file-based keychains [8]. The security command-line tool does not have access to entries managed by the modern macOS Passwords app (or items stored in the Data Protection/iCloud Keychain backend) [8]. Additionally, extracting passwords programmatically often triggers system authorization prompts, which may require user intervention unless access has been specifically granted to the binary [3][9].
Citations:
🌐 Web query:
site:github.com "security find-generic-password -s" '"acct"<blob>'💡 Result:
The command
security find-generic-password -s 'acct'is used on macOS to retrieve a generic password item from the Keychain that matches a specific service name [1][2][3]. In this command: -find-generic-passwordis the subcommand used to search for a generic password item [1][4]. --s 'acct'specifies the service name to match. The-sflag tells thesecuritytool to filter the search by the "service" attribute associated with the keychain item [1][2][3]. While your example includes<blob>, in a functional command, you would replace'acct'(and the surrounding syntax) with the actual name of the service registered in your Keychain [5][3]. For example, if you stored a password with the service name "MyService", you would run:security find-generic-password -s "MyService" -wThe-wflag is commonly used in this context to output only the password itself to standard output, making it useful for scripts and command-line automation [1][5][3]. If you do not use the-wor-gflags, the tool typically outputs descriptive information about the keychain item rather than the password itself [1][3].Citations:
🏁 Script executed:
Repository: Leuconoe/ClaudeCodeMultiAccounts
Length of output: 1598
🌐 Web query:
macOS security find-generic-password without -g output attributes stdout "attributes:"💡 Result:
To retrieve only the password from a generic keychain item using the macOS
securitycommand line tool without including extra attributes or metadata, use the-w(or--password) flag [1][2][3]. When this flag is used, the command outputs the raw password string directly to standard output (stdout) [1][2]. Example usage: security find-generic-password -s "YourServiceName" -a "YourAccountName" -w If you do not specify the-wflag, thesecuritytool will output verbose information, including attributes, metadata, and (if the-gflag is also used) the password prefixed with text like "password: " [2][4][5]. Key points regarding this command: - The-wflag is the primary mechanism to suppress attribute output and return only the password value [2][3]. - You may still be prompted for your macOS user password (or Keychain password) to authorize access to the stored secret, depending on your system's access control settings [1][6][7]. - If the command fails to find the item, it will typically print an error to standard error (stderr) and return a non-zero exit code [6]. - Because the password is printed to stdout, ensure you handle it securely (e.g., avoid echoing it or including it in shell history files if possible) [1].Citations:
🌐 Web query:
site:github.com "security find-generic-password" "attributes:" "acct"💡 Result:
In the context of the macOS security command line utility, acct is a core attribute tag representing the account name associated with a password item [1]. It corresponds to the SecAccountItemAttr constant in the underlying Apple Security framework [1]. When using the security find-generic-password command, you use the -a flag to specify or match this acct attribute [2]. Usage: To find a password for a specific account and service: security find-generic-password -a <account_name> -s <service_name> -w Key points: 1. Attribute Role: The acct attribute identifies the user account (or username) linked to a keychain item [1]. 2. Command-Line Flag: The -a flag is the standard interface for the acct attribute [2][3]. 3. Output Format: When inspecting keychain items, the acct attribute is often displayed in the output as "acct" =="" [4][5]. 4. Application: It is used in generic, Internet, and AppleShare password items to uniquely identify the account [1]. For example, to retrieve only the password (-w) for a specific service and account, use: security find-generic-password -s "my-service" -a "my-username" -w [3][6]
Citations:
🏁 Script executed:
Repository: Leuconoe/ClaudeCodeMultiAccounts
Length of output: 6149
Distinguish a missing Keychain item from a failed lookup.
execFileSyncreturns stdout, andsecurity find-generic-passwordprints"acct"<blob>in that output. Capture stderr for command diagnostics. Do not convert every lookup error intonull. Use the fallback account only when the item is explicitly absent. Propagate access, authorization, and parse errors before writing the new value; otherwise the write can update the wrong item without a backup.🤖 Prompt for AI Agents