Keep the keychain password off the command line on macOS - #601
Conversation
SaveCredential ran `security add-generic-password ... -w <password>`, so the password was in the security process's argument list while it ran. It now sends the command to `security -i` on stdin, with the password as hex (-X) so no character in it needs quoting. The only argument security gets is -i. It stays with /usr/bin/security instead of calling the Security framework: items saved so far let only /usr/bin/security read them, so reading them from the app would show a keychain prompt for each one. Reading back now returns exactly what was saved. GetCredential used `find-generic-password -w`, which prints hex for any password with a byte outside printable ASCII, and it trimmed leading and trailing spaces. It now reads the -g output, which marks hex values with 0x, and decodes them. The same decoding fixes usernames with a backslash (CORP\sa), which GetCredential could not read, and server names with a backslash (SQL01\PROD), which `planview credential list` left out. The service name and account are unchanged, so items saved by the current release are found, updated in place, and deleted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: a863029f-1003-4c4f-b2a8-90ec8cc43793
Off Windows a named pipe is a socket file under the temp folder, and macOS allows 104 bytes for its path. The test's full-GUID suffix made the path 131 bytes there, so ALineSentByTheClientReachesTheServer failed on macOS only. An 8-character suffix makes it 98. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza
|
Reviewed One thing to confirm (Low): The macOS-only tests are skipped on CI, so the Linux runner doesn't cover any of this. |
|
On the exit-code question: I checked Apple's source for |
What does this PR do?
On macOS, saving a credential keeps the password off the command line.
KeychainCredentialService.SaveCredentialused to runsecurity add-generic-password ... -w <password>, which put the password in the argument list of thesecurityprocess while it ran. It now sends that command tosecurity -ion stdin. The only argumentsecuritygets is-i. The password goes as hex (-X), so no character in it needs quoting.Why
security -iand not the Security frameworkAn item saved by the current release has an access list that lets only
/usr/bin/securityread its password. On the test Mac,security dump-keychain -ashowsapplications (1): /usr/bin/security. Reading these items through the Security framework shows a keychain prompt for every saved credential. Items the app saved itself trust the app's own binary instead. The macOS build is an unsigned zip, so each update brings the prompts back. Staying with/usr/bin/securitykeeps old and new items readable without prompts.Reading back returns exactly what was saved
The non-ASCII and space tests needed these changes to pass:
GetCredentialread the password withfind-generic-password -w. That prints hex for any password with a byte outside printable ASCII. The hex output looks the same as a password made of hex digits. It also trimmed leading and trailing spaces. It now reads the-goutput, which marks hex values with0x, and decodes them. Onesecuritycall now reads both the username and the password, instead of two calls.CORP\sa), whichGetCredentialfailed to read. It also fixes server names with a backslash (SQL01\PROD), whichplanview credential listleft out.The service name (
PlanViewer:<server>) and account are unchanged. Items saved by the current release are found, updated in place (still one item), and deleted.The one-line limit
A command sent to
security -imust be one line of at most 4094 bytes.securityreads each line into a 4096-byte buffer and runs whatever does not fit as a separate command. A save returns false and sends nothing when its line is too long, or when the server name or username has a newline. A password hits the limit at about 1,900 bytes.Which component(s) does this affect?
How was this tested?
Platform: macOS 27.0 on Apple silicon, .NET SDK 10.0.401. The Windows and Linux credential services are not changed.
New tests
KeychainCredentialServiceTestshas 28 cases. They run on macOS only and are skipped elsewhere. Each test makes its own throwaway keychain file, so the login keychain is never touched. Every call goes through a small script that logs its arguments and then runs/usr/bin/security.héllo wörld,密码🔑, a tab, a newline, an empty password,68656c6c6f(hex-looking), and-U(option-looking).-won the command line. The same passwords are tested.securityrun never contain the password or its hex. With the save switched back to-won the command line, this test fails.SQL01\PROD/CORP\svc_sql, names with spaces and quotes, and a non-ASCII server name read back and are listed.Manual checks against the real login keychain
The test items were removed afterwards.
-w) sees the new code's update.securityprocess had the password or its hex in its arguments.planview credential add 'SQL01\PROD_…' -u 'CORP\sa'with a non-ASCII password piped in, thencredential list(shows the server) andcredential remove.Build and suite
dotnet build PlanViewer.sln -c Release --no-incrementaland-c Debugboth have 0 warnings. Full suite on macOS at b579e45: 1,082 passed, 5 skipped, 1 failed.The failure was
SingleInstanceTests.ALineSentByTheClientReachesTheServer, which has nothing to do with the keychain. Its pipe name gave a socket path of 131 bytes under the macOS temp folder, and macOS allows 104. The app's own pipe path is 89 bytes, so only the test was affected. Commit 44dd389 shortens the test's pipe name, which makes the path 98 bytes. It passes on Windows and on the Linux CI runner, and has not been run on macOS.Checklist
dotnet build -c Debug)dotnet test): all passed on macOS except the pipe test that 44dd389 fixes, which has not been re-run there🤖 Generated with Claude Code