There are a high number of posts on this topic, but I was still not able to find a solution that worked well for me. I don’t believe my setup is that special, but I want everything to be declarative.

Short summary of what I’m using:

  • MacOS
  • NiX
  • Home-manager
  • Fish shell
  • No use of ssh-agent from earlier
  • YubiKey 5c

Note: The current version of OpenSSH on MacOS does not support the sk-options, so we will need to use a version of OpenSSH from the nixpkgs or homebrew. I’m using a version from nixpkgs.

Install the needed software.

On my system, all software is installed using nix. I had to enable homebrew to get one of the packages I needed.

  homebrew = {
    enable = true;
    onActivation = {
      autoUpdate = true;
      upgrade = true;
    };
    taps = [
      "jorgelbg/tap"
    ];
    brews = [
      "pinentry-touchid"
    ];
  };
}

I also made a really simple YubiKey module to install the software I need for nixpkgs:

{ pkgs, lib, config, ... }:
with lib; let
  cfg = config.features.cli.yubikey;
in {
  options.features.cli.yubikey.enable = mkEnableOption "Enable YubiKey Support";
  config = mkIf cfg.enable {
    home.packages = with pkgs; [
      openssh
      yubikey-manager
    ];
    home.file = {
      ".bin/ssh-askpass" = {
        enable = true;
        source = ../../files/ssh-askpass;
        executable = true;
      };
    };
  };
}

The shell script referred to can be found here.

I only made one small change, letting the script use pinentry-touchid istead of pinentry-mac.

Set PIN on the YubyKey

The pin can be set using ykman:

ykman fido access change-pin

Generate a ssh-key on the YubiKey

There is a bug in OpenSSH not honoring the -O no-touch-required setting. I believe this is fixed now, but it does not seem to be upstream yet. Bug report is found here. I want the no-touch-required setting to be enable so I don’t need to touch the key for every ssh operation involving the key. I only want to enter the PIN when the key is reseated.

So I’m generating the key with the no-touch-required option in the hopes that it will work soon.

How I generated my key:

ssh-keygen -t ed25519-sk -O resident -O no-touch-required -f ~/.ssh/id_ed25519-darwnix -O application=ssh:darwnix20241210 -N "" -C"Darwnix-2024-12-10"

The available security options when generating a ssh-key on a security key can be found here

The battle of ssh-agent

Since the version of OpenSSH shipped with MacOS does not support security keys, the same goes for ssh-agent. I had to add a few hacks to make this work. It’s not pretty, but it does the job.

I added the following functions to my config.fish

    set -gx SSH_ASKPASS /Users/$(whoami)/.bin/ssh-askpass
    set -gx DISPLAY ":0"
    if not pgrep -f 'ssh-agent -c' >/dev/null
        eval (ssh-agent -c -a /Users/davalex/.ssh-socket.fish)
    end
    if pgrep -f 'ssh-agent -l' >/dev/null
        kill $(pgrep -f 'ssh-agent -l')
    end
    set -gx SSH_AUTH_SOCK /Users/davalex/.ssh-socket.fish

My home-manager config for fish:

{ pkgs, lib, config, ... }:
with lib; let
  cfg = config.features.cli.fish;
in {
  options.features.cli.fish = {
    enable = mkEnableOption "Enable Fish shell";
    workstation = mkOption {
      type = types.bool;
      default = false;
      description = "Apply workstation-specific Fish shell configuration";
    };
  };

  config = mkIf cfg.enable {
    programs.fish = {
      enable = true;
      shellAliases = {
        ls = "eza";
        grep = "rg";
        ps = "procs";
        cat = "bat";
      };
      functions = mkIf cfg.workstation {
        rebuild = {
          body = ''
            cd ~/.config/nix-config/
            nix flake update
            darwin-rebuild check --flake .
            darwin-rebuild switch --flake .
            cd -
          '';
        };
        awsctx = {
          body = "set -gx AWS_PROFILE (aws configure list-profiles | fzf)";
        };
        rebuild-diff = {
          body = ''
            cd ~/.config/nix-config/
            nix flake update
            darwin-rebuild build --flake .
            nvd diff /run/current-system ./result
            rm result
            cd -
          '';
        };
      };
      shellAbbrs = mkIf cfg.workstation {
          ".." = "cd ..";
          "..." = "cd ../..";
      };
      interactiveShellInit = mkIf cfg.workstation ''
        set -gx LANG en_US.UTF-8
        set -gx EDITOR nvim
        set -gx VISUAL nvim
        set -U fish_user_paths /opt/homebrew/bin $fish_user_path $fish_user_paths
        set -gx SSH_ASKPASS /Users/$(whoami)/.bin/ssh-askpass
        set -gx DISPLAY ":0"
        if not pgrep -f 'ssh-agent -c' > /dev/null
            eval (ssh-agent -c -a /Users/$(whoami)/.ssh-socket.fish)
        end
        if pgrep -f 'ssh-agent -l' > /dev/null
            kill $(pgrep -f 'ssh-agent -l')
        end
        set -gx SSH_AUTH_SOCK /Users/$(whoami)/.ssh-socket.fish
      '';
    };
  };
}

This is a hack, but it makes sure the ssh-agent shipped with MacOS is killed and a new one started creating a socket in my home folder. The SSH_AUTH_SOCK is pointed at this sockets. This makes sure that the agent is available in a all shells at all times.

That should be about it. Now add start a fresh shell and add the keys to the agent:

ssh-add -K

And you should be good to go! Good luck! :)