diff --git a/README.md b/README.md index d555cb33..fb05c361 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,15 @@ If you find a bug, please open a GitHub Issue using the bug report template. Running the switch builds: - System settings (dark mode, key repeat, dock, Finder, trackpad) -- Homebrew apps (casks and CLI tools) +- Homebrew apps (casks and CLI tools, including VS Code Insiders) - Nix user packages (ripgrep, fd, fzf, jq, lazygit, Neovim, Hack Nerd Font) - Shell (zsh, aliases, starship prompt) - Editor (Neovim config with the rose-pine moon theme) - Terminal (WezTerm config with the rose-pine moon theme and dimmed unfocused windows) - Agent configs (Claude, Codex, opencode all share one AGENTS.md) - Optional Pi theme and local extensions, generic UI settings and model overrides, plus two deliberately pinned third-party Pi packages +- SSH server (Remote Login), key-only auth, for connecting from another machine on the LAN (e.g. VS Code Remote-SSH) +- Git identity, GitHub SSH auth, and commit signing, using a dedicated machine key pulled non-interactively from 1Password via a Service Account (no human required - this box runs unattended) ## Prerequisites @@ -91,19 +93,7 @@ If you clone it, review these before you run `bootstrap.sh`: All three have to match. - **CPU architecture**, `hostPlatform` in `configuration.nix` (see Prerequisites above). -**Git identity:** this config deliberately does not set your git name or email. -Git will stop your first commit and tell you to set them (`git config --global user.name "Your Name"` and `git config --global user.email you@example.com`). -If you'd rather manage that declaratively, add this back to `home.nix` with your own identity: - -```nix -programs.git = { - enable = true; - settings.user = { - name = "Your Name"; - email = "you@example.com"; - }; -}; -``` +**Git identity:** `user.name`/`email` aren't in this repo at all - they come from a `GIT_NAME`/`GIT_EMAIL` item in 1Password, materialized by `rebuild.sh` straight to `~/.config/git/config-local` (outside this repo, same as the SSH keys) and pulled in via `programs.git.includes`, so this public repo never hardcodes anyone's real name or email. `user.signingkey` points at `~/.ssh/id_ed25519_mac_signing`, which likewise doesn't exist until "GitHub SSH authentication & commit signing" below is done. If you clone this repo, add your own `GIT_NAME`/`GIT_EMAIL` item instead (see that section for the exact item shape), or just hardcode `programs.git.settings.user` in `home.nix` if you don't want the 1Password indirection. **Homebrew cleanup warning:** `configuration.nix` sets `homebrew.onActivation.cleanup = "zap"`. That means every time you switch, Homebrew removes any package or cask on your machine that isn't listed in the `brews` and `casks` arrays in `configuration.nix`. @@ -172,6 +162,48 @@ Both packages execute with your full user permissions and must be trusted like a Home Manager deliberately does not manage `~/.pi/agent` itself, or Pi authentication, sessions, trust decisions, caches, npm/git package trees, or any other runtime state. The model overrides contain no credentials or endpoint settings, do not choose a default model, and only take effect after you authenticate Pi yourself. This remains an additive post-video layer: it does not install Pi, a launcher, or package source code into this repository. +## Remote access (SSH / VS Code Remote-SSH) + +`configuration.nix` declares `services.openssh.enable = true;`, which turns on macOS's built-in Remote Login (the same sshd behind System Settings > Sharing) on every switch, and disables password authentication (`PasswordAuthentication no`, `KbdInteractiveAuthentication no`) so only key-based logins are accepted. + +`home/.ssh/authorized_keys` is symlinked to `~/.ssh/authorized_keys` and ships with a placeholder line. To let another machine (e.g. a Windows PC running VS Code Insiders' Remote-SSH extension) connect: + +1. On that machine, generate a keypair if you don't already have one: `ssh-keygen -t ed25519`. +2. Replace the placeholder line in `home/.ssh/authorized_keys` with the contents of the resulting `.pub` file. +3. Run `./rebuild.sh` on the Mac. + +This setup assumes both machines are on the same local network - it doesn't open any port on your router or configure a tunnel. For access from outside your LAN, put something like Tailscale in front of it rather than port-forwarding SSH directly to the internet. + +`visual-studio-code@insiders` is in the `casks` list, giving this Mac its own local VS Code Insiders install. That's separate from the Remote-SSH connection itself: when you connect from the Windows PC's VS Code Insiders, the Remote-SSH extension downloads and runs its own remote server component on the Mac automatically over the SSH connection the first time you connect - no separate install step for that part, since macOS already ships the `curl`/`tar` it needs. + +## GitHub SSH authentication & commit signing (1Password) + +This Mac runs unattended - nobody is sitting at it to approve a Touch ID prompt, and VNC-ing in every time git wants to push or sign a commit isn't "hands off." So this isn't 1Password's interactive SSH agent (that always requires a human to approve each use, every time it locks or restarts - fine for a laptop, not for a server). Instead: + +- A dedicated, non-default 1Password vault (`mac-automation`) holds two SSH keys used **only** by this machine: `dotfiles-mac-auth` and `dotfiles-mac-signing`, kept separate so a problem with one never touches the other. +- A **Service Account** scoped read-only to just that vault authenticates non-interactively - no vault unlock, no biometrics, no human required. +- `home/.ssh/*.tmpl` are committed templates containing only `op://` references (safe - no secrets). `rebuild.sh` runs `op inject` after every `darwin-rebuild switch` to materialize the real private keys, public keys, and `allowed_signers` straight into `~/.ssh/`, entirely outside both this git repo and the Nix store (which is world-readable, so secrets must never pass through it). +- `home/.ssh/config` routes `github.com` at the local materialized key directly (`IdentityAgent none`), and falls back to 1Password's interactive agent for every other host - so a human still gets the vault-gated, private-key-never-touches-disk experience for their own ad hoc SSH use. +- Git signing uses git's default `ssh-keygen`-based signer against the local key file - no `op-ssh-sign`, no 1Password dependency at commit time. +- The same vault also holds a `dotfiles-personal` item with `GIT_NAME`/`GIT_EMAIL` fields, injected into `~/.config/git/config-local` and pulled in via `programs.git.includes` - so this repo's `home.nix` never hardcodes anyone's real name or email either. + +**The trade-off, stated plainly:** the private keys now exist as ordinary files on this machine's disk, protected by Unix permissions and FileVault-at-rest - not "held only inside 1Password's vault." That's the same security posture as any standard CI/deploy key, not stronger. It's the accepted trade-off for unattended automation; it is a real downgrade from the interactive-agent model, not a wash. + +One-time setup: + +1. In 1Password, create the `mac-automation` vault (Service Accounts can't be granted access to your Personal/Private/Shared vault, so it has to be a fresh one). +2. In that vault, create `dotfiles-mac-auth` and `dotfiles-mac-signing` as SSH Key items (+ New Item > SSH Key > Generate, type ed25519), and a `dotfiles-personal` item (any item type with custom text fields) with `GIT_NAME` and `GIT_EMAIL` fields set to your actual name and email. +3. Create a Service Account, read-only, scoped to only the `mac-automation` vault. Copy its token immediately - 1Password shows it exactly once. +4. On this machine: `mkdir -p ~/.config/op && chmod 700 ~/.config/op`, save the token to `~/.config/op/service-account-token`, then `chmod 600` it. That path is outside `~/.dotfiles`, so it can never end up in this repo. +5. Run `./rebuild.sh`. It installs `1password-cli` (the `op` binary, via the `casks` list), then injects the keys and git identity into `~/.ssh/` and `~/.config/git/config-local`. +6. On GitHub, go to Settings > SSH and GPG keys > New SSH key. Add `~/.ssh/id_ed25519_mac_auth.pub`'s contents with key type "Authentication Key", and `~/.ssh/id_ed25519_mac_signing.pub`'s with key type "Signing Key". +7. Point this clone at GitHub over SSH: `git remote set-url origin git@github.com:/.git`. +8. Verify: `ssh -T git@github.com` should greet you by username with no prompt at all, and `git commit --allow-empty -m test && git log --show-signature -1` should show a good SSH signature. GitHub also shows a "Verified" badge on pushed commits signed this way. + +Rotating a key later is just: generate a new one in the 1Password item, re-run `./rebuild.sh` (`op inject --force` overwrites the local file), and update the GitHub-registered public key to match. + +If you're setting this up on a laptop you actually sit at instead of a headless box, skip all of this and just use 1Password's interactive SSH agent directly - point `home/.ssh/config`'s `IdentityAgent` at 1Password's socket for all hosts, point `git`'s `gpg.ssh.program` at `op-ssh-sign`, and accept the occasional Touch ID prompt in exchange for the private key never touching disk at all. That's a better trade for a machine a person is actually present at. + ## Notes The first time you launch `nvim`, it bootstraps [lazy.nvim](https://github.com/folke/lazy.nvim) by cloning plugins from GitHub. diff --git a/configuration.nix b/configuration.nix index 74baa399..66c1004f 100644 --- a/configuration.nix +++ b/configuration.nix @@ -12,6 +12,16 @@ home = "/Users/${user}"; }; system.stateVersion = 6; + # Remote Login (Apple's built-in sshd), for VS Code Remote-SSH from another + # machine on the LAN. Key-only: see home/.ssh/authorized_keys. + services.openssh = { + enable = true; + extraConfig = '' + PasswordAuthentication no + KbdInteractiveAuthentication no + MaxAuthTries 50 + ''; + }; system.defaults = { NSGlobalDomain = { AppleInterfaceStyle = "Dark"; @@ -40,6 +50,9 @@ casks = [ "wezterm" "claude-code" + "visual-studio-code@insiders" + "1password" + "1password-cli" ]; }; } diff --git a/flake.nix b/flake.nix index 96b6b8dd..8ad1a5b8 100644 --- a/flake.nix +++ b/flake.nix @@ -18,7 +18,7 @@ let # The one username line to change if this isn't your machine. # bootstrap.sh offers to rewrite this for you if your macOS username differs. - user = "kunchen"; + user = "coder"; in { darwinConfigurations."mac" = nix-darwin.lib.darwinSystem { @@ -32,6 +32,10 @@ home-manager.useUserPackages = true; home-manager.extraSpecialArgs = { inherit user; }; home-manager.users.${user} = import ./home.nix; + # If a file home-manager wants to symlink already exists on disk + # (e.g. 1Password writes ~/.ssh/config itself when you enable its + # SSH agent), back it up instead of failing activation. + home-manager.backupFileExtension = "backup"; } ]; }; diff --git a/home.nix b/home.nix index 540dc972..9c9da042 100644 --- a/home.nix +++ b/home.nix @@ -22,6 +22,29 @@ in fonts.fontconfig.enable = true; home.sessionVariables.EDITOR = "nvim"; + # GitHub auth and commit signing for this box. This machine runs + # unattended, so its keys are a dedicated pair pulled from a read-only + # 1Password Service Account into plain files at rebuild time (see + # rebuild.sh and README: "GitHub SSH authentication & commit signing"), + # rather than 1Password's interactive SSH agent, which always requires a + # human to approve each use. Signing therefore uses git's default + # ssh-keygen-based signer against the local key - no 1Password dependency + # at commit time, no prompts. user.name/email live in ~/.config/git/config-local + # instead, which rebuild.sh also materializes from 1Password - so this + # public repo's config never hardcodes anyone's real identity. + programs.git = { + enable = true; + includes = [ + { path = "${config.home.homeDirectory}/.config/git/config-local"; } + ]; + settings = { + user.signingkey = "${config.home.homeDirectory}/.ssh/id_ed25519_mac_signing"; + commit.gpgsign = true; + gpg.format = "ssh"; + gpg.ssh.allowedSignersFile = "${config.home.homeDirectory}/.ssh/allowed_signers"; + }; + }; + programs.zsh = { enable = true; autosuggestion.enable = true; # ghost text from history @@ -62,6 +85,14 @@ in config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/.config/herdr"; home.file.".claude/settings.json".source = config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/.claude/settings.json"; + home.file.".ssh/authorized_keys".source = + config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/.ssh/authorized_keys"; + home.file.".ssh/config".source = + config.lib.file.mkOutOfStoreSymlink "${dotfiles}/home/.ssh/config"; + # id_ed25519_mac_{auth,signing}(.pub) and allowed_signers are NOT declared + # here: they hold real key material (or are derived from it), so rebuild.sh + # writes them straight to ~/.ssh from 1Password via `op inject`, skipping + # both the Nix store (world-readable) and this git repo entirely. # Keep Pi's credential and runtime state local by linking only authored files and directories. home.file.".pi/agent/themes".source = diff --git a/home/.claude/settings.json b/home/.claude/settings.json index e3ab7750..ba6576f5 100644 --- a/home/.claude/settings.json +++ b/home/.claude/settings.json @@ -1,7 +1,28 @@ { - "theme": "dark-ansi", + "attribution": { + "commit": "", + "pr": "", + "sessionUrl": false + }, + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "command": "bash '/Users/coder/.claude/hooks/herdr-agent-state.sh' session", + "timeout": 10, + "type": "command" + } + ], + "matcher": "*" + } + ] + }, + "model": "sonnet", "statusLine": { - "type": "command", - "command": "input=$(cat); model=$(echo \"$input\" | jq -r '.model.display_name'); used=$(echo \"$input\" | jq -r '.context_window.used_percentage // empty'); if [ -n \"$used\" ]; then printf \"%s | ctx: %.0f%% used\" \"$model\" \"$used\"; else printf \"%s\" \"$model\"; fi" - } -} + "command": "input=$(cat); model=$(echo \"$input\" | jq -r '.model.display_name'); used=$(echo \"$input\" | jq -r '.context_window.used_percentage // empty'); if [ -n \"$used\" ]; then printf \"%s | ctx: %.0f%% used\" \"$model\" \"$used\"; else printf \"%s\" \"$model\"; fi", + "type": "command" + }, + "theme": "dark-ansi", + "tui": "fullscreen" +} \ No newline at end of file diff --git a/home/.config/git/config-local.tmpl b/home/.config/git/config-local.tmpl new file mode 100644 index 00000000..aacc3013 --- /dev/null +++ b/home/.config/git/config-local.tmpl @@ -0,0 +1,3 @@ +[user] + name = {{ op://mac-automation/dotfiles-personal/GIT_NAME }} + email = {{ op://mac-automation/dotfiles-personal/GIT_EMAIL }} diff --git a/home/.config/herdr/config.toml b/home/.config/herdr/config.toml index 8fa93ac2..5e79fbca 100644 --- a/home/.config/herdr/config.toml +++ b/home/.config/herdr/config.toml @@ -1,3 +1,4 @@ +onboarding = false [keys] prefix = "ctrl+b" focus_pane_left = "prefix+h" diff --git a/home/.ssh/allowed_signers.tmpl b/home/.ssh/allowed_signers.tmpl new file mode 100644 index 00000000..e7ac98c4 --- /dev/null +++ b/home/.ssh/allowed_signers.tmpl @@ -0,0 +1 @@ +sascha.s.krumbach@gmail.com {{ op://mac-automation/dotfiles-mac-signing/public key }} diff --git a/home/.ssh/authorized_keys b/home/.ssh/authorized_keys new file mode 100644 index 00000000..ad38f392 --- /dev/null +++ b/home/.ssh/authorized_keys @@ -0,0 +1,9 @@ +# Replace the placeholder below with your Windows PC's SSH public key. +# +# On the Windows PC, generate a keypair if you don't already have one: +# ssh-keygen -t ed25519 +# Then paste the contents of the resulting .pub file here (one line, starting +# with "ssh-ed25519" or "ssh-rsa"), replacing the placeholder line, and run +# ./rebuild.sh on the Mac to apply it. +# +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJi8OUIM0Esj2JG+LYij8dqG+RlqNoWjavfk40tckhCi diff --git a/home/.ssh/config b/home/.ssh/config new file mode 100644 index 00000000..a695fc1f --- /dev/null +++ b/home/.ssh/config @@ -0,0 +1,13 @@ +# github.com uses this machine's dedicated deploy-style key, materialized +# locally by rebuild.sh via a read-only 1Password Service Account (see +# README: "GitHub SSH authentication & commit signing"). No agent, no +# prompts - this box runs unattended and nobody is present to approve one. +Host github.com + IdentityFile ~/.ssh/id_ed25519_mac_auth + IdentitiesOnly yes + IdentityAgent none + +# Everything else still goes through 1Password's interactive SSH agent, for +# times a human is actually sitting at (or VNC'd into) this machine. +Host * + IdentityAgent "~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock" diff --git a/home/.ssh/id_ed25519_mac_auth.pub.tmpl b/home/.ssh/id_ed25519_mac_auth.pub.tmpl new file mode 100644 index 00000000..d6470839 --- /dev/null +++ b/home/.ssh/id_ed25519_mac_auth.pub.tmpl @@ -0,0 +1 @@ +{{ op://mac-automation/dotfiles-mac-auth/public key }} diff --git a/home/.ssh/id_ed25519_mac_auth.tmpl b/home/.ssh/id_ed25519_mac_auth.tmpl new file mode 100644 index 00000000..465a0cc3 --- /dev/null +++ b/home/.ssh/id_ed25519_mac_auth.tmpl @@ -0,0 +1 @@ +{{ op://mac-automation/dotfiles-mac-auth/private key?ssh-format=openssh }} diff --git a/home/.ssh/id_ed25519_mac_signing.pub.tmpl b/home/.ssh/id_ed25519_mac_signing.pub.tmpl new file mode 100644 index 00000000..05bc7b7a --- /dev/null +++ b/home/.ssh/id_ed25519_mac_signing.pub.tmpl @@ -0,0 +1 @@ +{{ op://mac-automation/dotfiles-mac-signing/public key }} diff --git a/home/.ssh/id_ed25519_mac_signing.tmpl b/home/.ssh/id_ed25519_mac_signing.tmpl new file mode 100644 index 00000000..2389df9f --- /dev/null +++ b/home/.ssh/id_ed25519_mac_signing.tmpl @@ -0,0 +1 @@ +{{ op://mac-automation/dotfiles-mac-signing/private key?ssh-format=openssh }} diff --git a/rebuild.sh b/rebuild.sh index b0b6bf18..946d65b9 100755 --- a/rebuild.sh +++ b/rebuild.sh @@ -2,4 +2,28 @@ set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" ln -sfn "$DIR" ~/.dotfiles -exec sudo darwin-rebuild switch --flake ~/.dotfiles#mac +sudo darwin-rebuild switch --flake ~/.dotfiles#mac + +# Materialize this machine's GitHub auth/signing keys from the +# "mac-automation" 1Password vault via a read-only Service Account. See +# README: "GitHub SSH authentication & commit signing". Skips quietly on a +# fresh clone before that token exists. +TOKEN_FILE="$HOME/.config/op/service-account-token" +if [[ -f "$TOKEN_FILE" ]]; then + export OP_SERVICE_ACCOUNT_TOKEN + OP_SERVICE_ACCOUNT_TOKEN="$(cat "$TOKEN_FILE")" + for name in id_ed25519_mac_auth id_ed25519_mac_signing; do + op inject --force -i "$DIR/home/.ssh/$name.tmpl" -o "$HOME/.ssh/$name" + op inject --force -i "$DIR/home/.ssh/$name.pub.tmpl" -o "$HOME/.ssh/$name.pub" + chmod 600 "$HOME/.ssh/$name" + chmod 644 "$HOME/.ssh/$name.pub" + done + op inject --force -i "$DIR/home/.ssh/allowed_signers.tmpl" -o "$HOME/.ssh/allowed_signers" + chmod 644 "$HOME/.ssh/allowed_signers" + + mkdir -p "$HOME/.config/git" + op inject --force -i "$DIR/home/.config/git/config-local.tmpl" -o "$HOME/.config/git/config-local" + chmod 644 "$HOME/.config/git/config-local" +else + echo "note: $TOKEN_FILE not found - skipping 1Password key injection (see README)" >&2 +fi