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>
109 lines
5.2 KiB
Markdown
109 lines
5.2 KiB
Markdown
# 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.
|