osx-keychain Released on Opam

My first Opam-published package, and why I built it.

The osx-keychain library I built to natively pull credentials from the macOS keychain was accepted into Opam and is now publicly available as a dependency, a first for me ๐Ÿฅณ. Here is some context on why I made it:

There are two modes in which I interact with AWS for the alexleighton.com static site: during site upload, and during image upload.

Site Upload

I used to rely on GitHub Actions to build and deploy all commits on the main branch, but it ended up being the worst of all worlds. Reinstalling opam, dune, the OCaml compiler, and all dependencies often took over 10 minutes, regardless of the Actions-caching I tried to apply. My static site is a private repository, so GitHub eventually started nagging about reaching the 1000 monthly build hours threshold for private repos. As a result I turned off the automation and have been building/uploading my site straight off my laptop using a bash script that does what the Action did.

# <scripts/build-and-deploy-local.sh>
main() {
    # Parse command line arguments
    DEPLOY=true
    while [[ $# -gt 0 ]]; do
        # ...
    done

    print_status "Starting build process with options: DEPLOY=${DEPLOY}"

    check_tool_dependencies
    setup_environment
    check_dependencies
    clean_build_artifacts

    build_project
    run_tests

    build_site
    create_archive

    if [ "$DEPLOY" = true ]; then
        deploy_to_s3
        trigger_amplify
        monitor_deployment
    else
        print_status "Build completed locally."
    fi

    deployment_summary
    print_success "๐ŸŽ‰ Build and deploy process completed!"
}
...
deploy_to_s3() {
    ...
    print_status "Deploying to S3..."

    aws s3api put-object \
        --bucket site \
        --key "${ZIP_NAME}" \
        --body "${ZIP_PATH}" \
        --tagging "archive=no"

    print_success "Uploaded ${ZIP_NAME} to S3"
}

Both the upload to S3 and the Amplify triggering and monitoring see the aws CLI pulling my limited developer IAM credentials from the usual dotfile location (~/.aws/credentials). Moving to keychain-stored credentials consisted of emptying the credentials file and filling the config file with:

# <~/.aws/config>
[default]
region = us-west-2
credential_process = /usr/bin/security find-generic-password -s aws -a default -w

Image Upload

As I describe in more detail here, each image added to the system is processed into multiple variants, each uploaded to S3. The code for this image system is shared by both the uploader executable and the site generator executable so that they speak the same image language without duplication. It is this image system library that I updated to use osx-keychain to pull the AWS credentials from the keychain.

I still work on the site from a Linux machine from time to time, so I need the code to support both macOS and Linux, and use dune's select to pick the backend at build time:

(library
 (name asite)
 (public_name asite)
 (libraries
   ...
   ; Keychain-backed AWS credentials: link osx-keychain when installed,
   ; otherwise fall back to a stub so asite still builds on Linux.
   ; See lib/keychainBackend.mli.
   (select keychainBackend.ml from
     (osx-keychain -> keychainBackend_macos.ml)
     (-> keychainBackend_stub.ml))
  )
)

osx-keychain is wrapped in a keychainBackend so it can be stubbed on Linux.

(* keychainBackend.mli *)
(** [get ~service ~account] looks up a generic-password item.

    - [Ok (Some secret)] โ€” the item exists.
    - [Ok None] โ€” no such item, or this platform has no keychain.
    - [Error msg] โ€” a genuine keychain failure (e.g. access denied). *)
val get : service:string
       -> account:string
       -> (string option, string) result

(* keychainBackend_stub.ml *)
let get ~service:_ ~account:_ = Ok None

(* keychainBackend_macos.ml *)
let get ~service ~account =
  match Osx_keychain.Generic_password.get ~service ~account () with
  | Ok secret -> Ok secret
  | Error e -> Error (Osx_keychain.to_string e)

The code checks the keychain backend first, falling back to the AWS credentials file on the Linux box.

Against Keychain Nagging

Apple's security CLI is a signed binary, so when the keychain access approval dialog pops up, "Always Allow" puts the security binary's identity into the always allowed list. On a developer machine, where the OCaml binary is rebuilt when code changes, "Always Allow" does nothing because the binary's identity changes after each compilation. To avoid the nag, an additional dune step makes the binary's identity stable โ€” signing the binary with a self-signed identity, so the keychain remembers it by the stable identity (identifier plus certificate) rather than the ad-hoc cdhash the linker assigns fresh on every build. One "Always Allow" then sticks across recompiles.

; Build your binary with a different name,
; copy it back into place.
(rule
 (target asite.exe)
 (enabled_if (<> %{system} macosx))
 (deps asite_main.exe)
 (action (copy asite_main.exe asite.exe)))

...

; The runnable binary is asite.exe, produced from the linked asite_main.exe.
; On macOS we sign it with the local self-signed identity (created by
; scripts/setup-codesign.sh) so its in-process keychain reads don't prompt: the
; designated requirement (identifier + cert leaf) is stable across rebuilds,
; unlike the ad-hoc cdhash the linker assigns. If the identity isn't installed
; we warn and keep the ad-hoc signature, so a build never breaks. Off macOS the
; binary is copied as-is.
(rule
 (target asite.exe)
 (enabled_if (= %{system} macosx))
 (deps asite_main.exe)
 (action
  (progn
   (copy asite_main.exe asite.exe)
   (system
    "codesign -f -s asite-codesign --identifier com.alexleighton.asite %{target} || echo 'sign: code-signing identity asite-codesign not found; asite.exe left ad-hoc signed (run scripts/setup-codesign.sh)' >&2"))))

A helper <scripts/setup-codesign.sh> script for generating the certificate for self-signing:

#!/usr/bin/env bash
set -euo pipefail

# Creates the local self-signed code-signing identity used to sign asite.exe so
# its in-process keychain reads don't prompt after every rebuild. macOS-only.
#
# The private key stays in your login keychain and is NEVER exported or
# committed. A fresh machine just re-runs this to mint a new local identity.

IDENTITY_CN="asite-codesign"
IDENTIFIER="com.alexleighton.asite"
LOGIN_KC="$HOME/Library/Keychains/login.keychain-db"

if [[ "$(uname)" != "Darwin" ]]; then exit 0; fi

if security find-identity -v -p codesigning "$LOGIN_KC" | grep -q "$IDENTITY_CN"; then
  echo "Code-signing identity '$IDENTITY_CN' already present. Nothing to do."
  exit 0
fi

echo "Creating self-signed code-signing identity '$IDENTITY_CN'..."
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT

# Self-signed cert with the codeSigning extended key usage.
openssl req -x509 -newkey rsa:2048 -keyout "$tmp/ck.key" -out "$tmp/ck.crt" -days 3650 -nodes \
  -subj "/CN=$IDENTITY_CN" \
  -addext "basicConstraints=critical,CA:false" \
  -addext "keyUsage=critical,digitalSignature" \
  -addext "extendedKeyUsage=critical,codeSigning" >/dev/null 2>&1

# -legacy: macOS `security import` can't verify OpenSSL 3.x's default PKCS#12 MAC.
openssl pkcs12 -export -legacy -inkey "$tmp/ck.key" -in "$tmp/ck.crt" -out "$tmp/ck.p12" \
  -passout pass:asite -name "$IDENTITY_CN" >/dev/null 2>&1

echo "You may be prompted for your login password (to import the key and trust the cert)."
# -T /usr/bin/codesign pre-authorizes codesign to use the key.
security import "$tmp/ck.p12" -k "$LOGIN_KC" -P asite -T /usr/bin/codesign
# Trust the self-signed cert for code signing (user domain โ€” no sudo).
security add-trusted-cert -r trustRoot -p codeSign -k "$LOGIN_KC" "$tmp/ck.crt"

echo
echo "Done. Identity '$IDENTITY_CN' is ready."
echo "The dune build now signs asite.exe with it automatically."

Conclusion

With these pieces in place I'm back to the status quo, but without a credential sitting in my user filesystem waiting to be read.