# ssh-scripts SSH key / authentication tooling. These notes travel with the repo. ## ssh-copy-id-win A personal fork of `ssh-copy-id` that supports Windows OpenSSH servers (both regular users and Administrators) while keeping full *nix behaviour. **This repo is the source of truth**; `~/.local/bin/ssh-copy-id-win` is a *deployed copy* on the fish PATH. They are separate files, so edits here do not take effect until redeployed: ``` cp ./ssh-copy-id-win ~/.local/bin/ssh-copy-id-win # deploy diff ./ssh-copy-id-win ~/.local/bin/ssh-copy-id-win # check for drift ``` Edit the repo copy, not the deployed one. If they ever disagree, the deployed copy is the one that has been running. ``` ssh-copy-id-win [-i identity] [-p port] [-o ssh_opt] [-A|-U] [-n] [user@]host -A / -U force the Administrator / regular-user path on Windows -n dry run, prints the exact remote command ``` ### Design constraints (do not regress these) * **cmd.exe only.** Everything is dispatched as `cmd.exe /c ""` because a host's sshd `DefaultShell` is unpredictable. PowerShell is never assumed to exist on a target. * **Exactly one quote pair, zero quotes inside ``.** If the remote default shell is PowerShell, sshd runs `powershell -c "cmd.exe /c \"\""` and PowerShell re-escapes nested quotes as `\"`, which cmd.exe does not understand. To stay quote-free the script `cd /d %USERPROFILE%` (or `%ProgramData%`) first and then uses short relative paths that cannot contain spaces, and dedups on the base64 key blob alone since that field is whitespace-free. * **No hard-coded drive letters.** `%USERPROFILE%` and `%ProgramData%` only. * **Admins need the ProgramData file.** Stock Windows `sshd_config` has a `Match Group administrators` block making `%ProgramData%\ssh\administrators_authorized_keys` the *only* file consulted for admin accounts. It also requires a restrictive ACL (SYSTEM + Administrators), which the script reapplies after every write. * **Locale independence.** Never match English text in Windows command output. Admin detection uses the `S-1-5-32-544` SID; ACLs are granted by SID (`*S-1-5-32-544`, `*S-1-5-18`). * **One connection.** All steps share a multiplexed `ControlMaster` connection so password-auth hosts prompt once, not once per step. ### Two cmd.exe traps that fail silently Both of these produced a *corrupt file with no error message*, and both cost real debugging time. They are also commented in the script header. 1. **Trailing space from a mid-line redirect.** `echo KEY>>file && echo DONE` — cmd lifts the redirect out of the middle, so the space before `&&` ends up inside the echoed text. Every key landed as `...comment ` with a trailing space, so the next run's exact-match dedup never matched and appended a duplicate. Forever. Use `(echo KEY)>>file` instead. 2. **`if` swallowing the rest of the chain.** `if not exist ssh mkdir ssh & findstr ... && echo A || echo B` — when the directory *already exists*, cmd treats the entire remainder as the if-body and skips all of it, so the command produces no output at all. Parenthesise it: `(if not exist ssh mkdir ssh) & ...` ## Testing Verified paths: Linux, Windows Administrator, Windows regular user — each for both first-install and duplicate detection. The three targets used were a Linux host, a domain Windows host reached as a member of Administrators (key auth), and the same Windows host reached as an ordinary domain user (**password auth only**). Specific hostnames and accounts are deliberately kept out of this repo. Conventions when working on this: * **Password-auth hosts cannot be tested from inside a Claude Code session.** The Bash tool and the `!` prefix give ssh no TTY, so it dies with exit 255. Ask the user to run those in a real terminal. * Test additive changes with a throwaway `ssh-keygen` key rather than the real one — it never risks locking you out of the box you are testing on. * When rewriting a remote `authorized_keys`, stage to `%TEMP%` and `copy /y` over the target, so the original survives a failed write. * Where `pwsh` 7 happens to be installed on a target it is fine for *debugging only* — the script must never depend on it. `pwsh -EncodedCommand` (base64 UTF-16LE) sidesteps all quoting layers; `certutil -encodehex ` gives byte-level output for spotting trailing whitespace and CRLF issues, and needs nothing beyond cmd. * Test against a **non-English Windows locale** if you can. Command output is localised, which is exactly why group and ACL checks match on SIDs (`S-1-5-32-544`, `S-1-5-18`) rather than on names like "Administrators". * Always verify a written key by *authenticating with it*, not just by reading the file back. ## Local environment The user's shell is **fish**, but the Bash tool runs bash/zsh. Checking `$PATH`, aliases, or functions through the Bash tool reflects the wrong shell — verify with `fish -c '...'`, and persist path changes with `fish_add_path`. Note `ssh-copy-id` upstream picks its default key with `ls -dt ~/.ssh/id*.pub | head -1` — i.e. by **mtime**, newest first — whereas `ssh` itself uses fixed default filenames (`id_rsa`, `id_ecdsa`, `id_ed25519`, ...). The two can disagree, which is confusing when a `touch` reorders things.