A fork of ssh-copy-id that installs keys on Windows OpenSSH servers, for both regular users and members of Administrators, while keeping the standard behaviour on *nix targets. Windows design constraints: - Dispatches everything as `cmd.exe /c "<inner>"`, since a host's sshd DefaultShell is unpredictable. PowerShell is never assumed present. - Uses exactly one quote pair with no quotes inside <inner>. A PowerShell DefaultShell re-escapes nested quotes as \", which cmd.exe cannot parse. Staying quote-free means cd'ing to the base directory first and using short relative paths, and deduping on the whitespace-free base64 blob. - No hard-coded drive letters; %USERPROFILE% and %ProgramData% only. - Administrators are written to administrators_authorized_keys with the SYSTEM + Administrators ACL that Windows sshd requires, because the stock Match Group administrators block makes it the only file consulted for those accounts. - Group and ACL checks match on SIDs, not names, so they work on non-English Windows locales. - All steps share one multiplexed connection, so password-auth hosts prompt once rather than once per step. Two cmd.exe parsing traps are worked around, both of which corrupted the remote file with no error output: - `echo KEY>>file && echo DONE` leaves the space before `&&` inside the echoed text, appending a trailing space that defeats exact-match dedup and duplicates the key on every run. Uses `(echo KEY)>>file` instead. - An unparenthesised `if not exist ... & ...` swallows the rest of the command chain when the directory already exists, silently skipping everything after it. Verified on Linux, Windows Administrator, and Windows regular-user targets, each for both first install and duplicate detection. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5.2 KiB
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 "<inner>"because a host's sshdDefaultShellis unpredictable. PowerShell is never assumed to exist on a target. - Exactly one quote pair, zero quotes inside
<inner>. If the remote default shell is PowerShell, sshd runspowershell -c "cmd.exe /c \"<inner>\""and PowerShell re-escapes nested quotes as\", which cmd.exe does not understand. To stay quote-free the scriptcd /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_confighas aMatch Group administratorsblock making%ProgramData%\ssh\administrators_authorized_keysthe 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-544SID; ACLs are granted by SID (*S-1-5-32-544,*S-1-5-18). - One connection. All steps share a multiplexed
ControlMasterconnection 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.
-
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...commentwith a trailing space, so the next run's exact-match dedup never matched and appended a duplicate. Forever. Use(echo KEY)>>fileinstead. -
ifswallowing 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-keygenkey 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%andcopy /yover the target, so the original survives a failed write. - Where
pwsh7 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 <in> <out>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.