Add ssh-copy-id-win: ssh-copy-id with Windows OpenSSH support

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>
This commit is contained in:
2026-08-13 12:11:09 -04:00
co-authored by Claude Opus 5
commit fd022a195c
3 changed files with 396 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
# 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 sshd `DefaultShell` is 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 runs `powershell -c "cmd.exe /c \"<inner>\""` 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 <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.