Important Correction: 12 Jan, 2024
In the initial version of this blog, I described a method to pass the secret to the security
command using a subshell. This is not secure, as the secret is exposed to process exploration tools such as ps
. I have updated the blog to use a custom tool written in go to pass the secret. I wish to thank my respected peer at Recurse Center, Jacob Vosmaer, for pointing this issue out.
For one of my current projects, I’m trying to create a secure way to store and access secrets such as API keys on my Mac, such that they can be accessed from bash scripts. I’m using the MacOS Keychain to store these secrets, so I decided to write about it because it is generally useful. Ideally, I would use a yubikey or something similar to store and access these secrets, but this alternative is an interesting option, and it balances security and convenience.
Before using this process I hardened the default settings for my Keychain. I want to make sure that the Keychain locks after a period of inactivity. To do this, I followed the steps below:
Open Keychain Access:
Adjust Auto-Lock Settings:
When these settings are in place, and your Keychain locks, accessing it again should prompt for your Mac login password. This is preferable to having the Keychain unlocked all the time.
Unfortunately, I haven’t found a way to require Touch ID for every use of the Keychain. I might revisit this later and write an update after contacting Apple Support. This would be an excellent feature to add to the Keychain, biometric authentication for every use on some chains? If that is not a planned feature, I will revisit this and write an update that uses a yubikey or something similar.
Apple provides a command-line interface to create and retrieve secrets in macOS Keychain, this is especially handy for bash scripts. It is under the security
command as two subcommands add-generic-password
and find-generic-password
.
To securely store a new secret item in the keychain, normally we would use the command security add-generic-password
. Like this:
security add-generic-password -U -a "MyAPIUser" -s "MyAPIService" -w "thepassword"
Where:
security add-generic-password
: Adds a password to the Keychain.-a "MyAPIUser"
: Sets the service user name (here, “MyAPIUser”).-s "MyAPIService"
: Sets the service name (here, “MyAPIService”).-w "thepassword"
: Sets the password (here, “thepassword”).-U
: Updates the item if it already exists in the keystore.But this is where things got inconvenient for me. In fact, I struggled to find a way to pass the secret to the security
command without exposing it in the terminal history or environment.
I tried using a subshell to pass the value to -w
, and I tried using a temporary file and redirection. But, I couldn’t get either to work in a way that didn’t expose the secret or just fail to be read by the security
command as intended.
After the error was spotted in my initial solution, I paused, and took a step back. I realized that I could write a tool to do this. Its not as elegant as pure shell scripting, but it works. I have included the code as a gist at the end of this blog. To use the tool, you simply compile it and pass the secret inside of a file.
Here’s an example of running it:
glitch@cafe% printf "a secret" > secret.txt # don't do this in real life
glitch@cafe% ./keychain-tool -account "MyAPIUser" -service "MyAPIService" -file secret.txt
Duplicate item found. Updating existing item...
When retrieving the secret, it’s crucial not to print it to the console. Instead, you should pipe the output directly to the command that requires the secret. Or pass it via an environment variable. However, make sure to unset the environment variable after use. Here is an example of retrieving the value of the secret that we stored above:
Here’s an example:
security find-generic-password -a "MyAPIUser" -s "MyAPIService" -w | <command_that_needs_the_secret>
Where:
security find-generic-password
: This is a command to retrieve a stored password from the macOS Keychain.-a "MyAPIUser"
: Specifies the account name associated with the password.-s "MyAPIService"
: Indicates the service name for which the password is stored.-w
: Directs the command to output only the password itself, not the entire Keychain item object.|
: This is a pipe symbol that passes the output of the preceding command as input to another command.<command_that_needs_the_secret>
: Replace this with the command that needs the API key. The key is passed directly to this command without being printed or stored in a temporary variable.This approach ensures the API key remains secure and is not exposed in the terminal history or environment.
This example involves storing and retrieving an ssh private key from the keystore. The key is stored in the Keychain using our custom tool keychain-tool
, and encoded in base64 to avoid issues with special characters.
When needed, it’s retrieved, decoded, and passed to ssh-add
via a temporary file. The temporary file is created with mktemp
and securely overwritten with random data after use. Clearly this is not the most secure method, but I think it is better than persistently storing the key in a file on disk. A much better method would be to use a yubikey or something similar, so that the key is completely stored in hardware.
glitch@cafe% cd ~/.ssh/
glitch@cafe% base64 -i the-key.pem > sshkey.b64 # base64 encoded ssh key
glitch@cafe% ./keychain-tool -account "ec2user" -service "MySSHHost" -file sshkey.b64
glitch@cafe% dd if=/dev/urandom of=sshkey.b64 bs=1 count=$(stat -f%z sshkey.b64)
glitch@cafe% rm sshkey.b64
Note: The dd
command is used to securely overwrite the temporary file with random data. This is done to prevent the secret from being recovered from the disk.
if=/dev/urandom
: Specifies the input file as /dev/urandom
, which generates pseudo-random bytes.of=sshkey.txt
: Specifies the output file where the random data will be written.bs=1
: Sets the block size to 1 byte. This means dd
will read and write up to 1 byte at a time.count=$(stat -f%z sshkey.txt)
: Uses the stat
command to get the size of sshkey.txt
in bytes and sets this as the count
. This tells dd
to write only as many bytes as the size of sshkey.txt
.The purpose of this command is to overwrite the file sshkey.txt
with random data, effectively making the original content unrecoverable. This is often done for security reasons to securely erase sensitive data.
# Create a secure temporary file in the home directory
tmpfile=$(mktemp ~/sshkey.XXXXXX)
chmod 600 "$tmpfile"
# Retrieve the SSH key from Keychain and decode it into the temp file
security find-generic-password -a "ec2user" -s "MySSHHost" -w | base64 --decode > "$tmpfile"
# Load the SSH key into ssh-agent
ssh-add "$tmpfile" # ssh-agent requires a file with restricted permissions, so we can't use and fd/pipe
# Securely overwrite the temporary file with random data
dd if=/dev/urandom of="$tmpfile" bs=1 count=$(stat -f%z "$tmpfile")
# Remove the temporary file
rm "$tmpfile"
You may need to remove the secret. To do so, you can use the security delete-generic-password
command. This command requires the account name and service name, which you can find using the security find-generic-password
command. Heres an example using out ssh key from above:
security delete-generic-password -a "ec2user" -s "MySSHHost"
I hope this helps someone. I’m going to try to write and make more this year. Sending warmth and kindness to my reader. As always, I hope you are well.
go mod init example.com/m/v2
go mod tidy
go build -o keychain-tool