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:
+11
@@ -0,0 +1,11 @@
|
||||
# Never commit key material from this scratch dir.
|
||||
id_*
|
||||
*.pem
|
||||
*.key
|
||||
*.ppk
|
||||
known_hosts*
|
||||
authorized_keys*
|
||||
|
||||
# Allow public keys only if deliberately added with `git add -f`.
|
||||
!*.pub
|
||||
*.pub
|
||||
@@ -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.
|
||||
Executable
+277
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# ssh-copy-id-win — ssh-copy-id that also works against Windows OpenSSH
|
||||
# servers (regular users and Administrators), while keeping full *nix
|
||||
# compatibility.
|
||||
#
|
||||
# Windows design notes:
|
||||
#
|
||||
# * cmd.exe only. Every remote command is dispatched as
|
||||
# cmd.exe /c "<inner>"
|
||||
# so it does not matter what sshd has configured as DefaultShell.
|
||||
#
|
||||
# * ZERO quotes inside <inner>. This is deliberate and load-bearing:
|
||||
# if the remote DefaultShell is PowerShell, sshd runs
|
||||
# powershell -c "cmd.exe /c \"<inner>\""
|
||||
# and PowerShell re-escapes nested quotes as \" — which cmd.exe does
|
||||
# not understand. With exactly one quote pair (the outer one) the
|
||||
# string survives both shells identically. To achieve that:
|
||||
# - `cd /d %USERPROFILE%` first, then use short relative paths that
|
||||
# cannot contain spaces (.ssh\authorized_keys). `cd` consumes the
|
||||
# rest of its argument, so an unquoted path with spaces is fine.
|
||||
# - search on the base64 key blob only, which is whitespace-free.
|
||||
#
|
||||
# * Appends with `(echo LINE)>>file`, never `echo LINE>>file && ...`.
|
||||
# In the latter, cmd lifts the redirect out of the middle and the
|
||||
# space before `&&` ends up INSIDE the echoed text, producing a
|
||||
# trailing space. That breaks any later exact-match dedup and silently
|
||||
# appends a duplicate key on every run.
|
||||
#
|
||||
# * No hard-coded drive letters: %USERPROFILE% / %ProgramData% only.
|
||||
#
|
||||
# * Administrators are written to
|
||||
# %ProgramData%\ssh\administrators_authorized_keys, because the stock
|
||||
# Windows sshd_config has a `Match Group administrators` block that
|
||||
# makes that file the ONLY one consulted for admin accounts. The
|
||||
# required restrictive ACL (SYSTEM + Administrators) is applied after.
|
||||
|
||||
set -u
|
||||
|
||||
PROG=$(basename "$0")
|
||||
IDENTITY=""
|
||||
PORT=""
|
||||
SSH_OPTS=()
|
||||
FORCE_MODE="" # "" = autodetect, "admin", "user"
|
||||
DRY_RUN=0
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $PROG [-i identity_file] [-p port] [-o ssh_option] [-A|-U] [-n] [user@]hostname
|
||||
|
||||
-i identity_file Public key to install. Defaults to the most recently
|
||||
modified ~/.ssh/id*.pub (same rule as ssh-copy-id).
|
||||
-p port Remote SSH port.
|
||||
-o ssh_option Extra option passed through to ssh (repeatable).
|
||||
-A Force the Administrator path on Windows.
|
||||
-U Force the regular-user path on Windows.
|
||||
-n Dry run: print what would happen, change nothing.
|
||||
-h This help.
|
||||
EOF
|
||||
}
|
||||
|
||||
while getopts ":i:p:o:AUnh" opt; do
|
||||
case "$opt" in
|
||||
i) IDENTITY=$OPTARG ;;
|
||||
p) PORT=$OPTARG ;;
|
||||
o) SSH_OPTS+=(-o "$OPTARG") ;;
|
||||
A) FORCE_MODE="admin" ;;
|
||||
U) FORCE_MODE="user" ;;
|
||||
n) DRY_RUN=1 ;;
|
||||
h) usage; exit 0 ;;
|
||||
\?) echo "$PROG: unknown option -$OPTARG" >&2; usage >&2; exit 1 ;;
|
||||
:) echo "$PROG: option -$OPTARG requires an argument" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
shift $((OPTIND - 1))
|
||||
|
||||
TARGET=${1:-}
|
||||
if [ -z "$TARGET" ]; then
|
||||
echo "$PROG: missing [user@]hostname" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PORT_OPTS=()
|
||||
[ -n "$PORT" ] && PORT_OPTS=(-p "$PORT")
|
||||
|
||||
# This script makes several round trips (probe OS, detect admin, install,
|
||||
# set ACL). On a password-auth host that would mean re-entering the password
|
||||
# for each one, so all of them share a single multiplexed connection: you
|
||||
# authenticate once and the rest ride along.
|
||||
CTL_DIR=$(mktemp -d "${TMPDIR:-/tmp}/ssh-copy-id-win.XXXXXX")
|
||||
CTL_PATH="$CTL_DIR/s"
|
||||
|
||||
cleanup() {
|
||||
ssh -o ControlPath="$CTL_PATH" -O exit "$TARGET" >/dev/null 2>&1
|
||||
rm -rf "$CTL_DIR"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
ssh_run() {
|
||||
# LogLevel=ERROR keeps banners/warnings (e.g. the post-quantum notice) out
|
||||
# of the output we parse for result markers.
|
||||
ssh -o LogLevel=ERROR \
|
||||
-o ControlMaster=auto -o ControlPath="$CTL_PATH" -o ControlPersist=60 \
|
||||
-o PreferredAuthentications=publickey,keyboard-interactive,password \
|
||||
"${PORT_OPTS[@]}" "${SSH_OPTS[@]}" "$TARGET" "$@"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- identity
|
||||
if [ -z "$IDENTITY" ]; then
|
||||
IDENTITY=$(ls -dt "$HOME"/.ssh/id*.pub 2>/dev/null | grep -v -- '-cert\.pub$' | head -n 1)
|
||||
[ -n "$IDENTITY" ] || { echo "$PROG: no identity found and none given with -i" >&2; exit 1; }
|
||||
else
|
||||
case "$IDENTITY" in
|
||||
*.pub) : ;;
|
||||
*) [ -f "${IDENTITY}.pub" ] && IDENTITY="${IDENTITY}.pub" ;;
|
||||
esac
|
||||
fi
|
||||
[ -f "$IDENTITY" ] || { echo "$PROG: identity file not found: $IDENTITY" >&2; exit 1; }
|
||||
|
||||
# First non-blank line, CR stripped, whitespace collapsed.
|
||||
PUBKEY_LINE=$(tr -d '\r' < "$IDENTITY" | grep -v '^[[:space:]]*$' | head -n 1 \
|
||||
| sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/[[:space:]][[:space:]]*/ /g')
|
||||
[ -n "$PUBKEY_LINE" ] || { echo "$PROG: $IDENTITY contains no key" >&2; exit 1; }
|
||||
|
||||
echo "$PROG: using identity: $IDENTITY" >&2
|
||||
|
||||
# ------------------------------------------------------------ OS detection
|
||||
# Deliberately explicit: a failed connection must NOT be mistaken for
|
||||
# "not *nix, therefore Windows".
|
||||
PROBE_ERR="$CTL_DIR/probe.err"
|
||||
UNAME_OUT=$(ssh_run uname -s 2>"$PROBE_ERR")
|
||||
SSH_RC=$?
|
||||
|
||||
if [ $SSH_RC -eq 255 ]; then
|
||||
echo "$PROG: cannot connect to $TARGET" >&2
|
||||
[ -s "$PROBE_ERR" ] && sed "s/^/$PROG: ssh: /" "$PROBE_ERR" >&2
|
||||
echo "$PROG: (if this host uses password auth, run from an interactive" >&2
|
||||
echo "$PROG: terminal so ssh can prompt for it)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REMOTE_OS=""
|
||||
case "$UNAME_OUT" in
|
||||
*Linux*|*Darwin*|*BSD*|*SunOS*|*AIX*|*CYGWIN*|*MINGW*|*MSYS*) REMOTE_OS="nix" ;;
|
||||
esac
|
||||
|
||||
if [ -z "$REMOTE_OS" ]; then
|
||||
# Probe for Windows rather than assuming it.
|
||||
VER_OUT=$(ssh_run 'cmd.exe /c ver' 2>/dev/null)
|
||||
case "$VER_OUT" in
|
||||
*Windows*|*windows*) REMOTE_OS="windows" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [ -z "$REMOTE_OS" ]; then
|
||||
echo "$PROG: could not determine remote OS (uname gave '$UNAME_OUT')" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# =========================================================== *nix remote ==
|
||||
if [ "$REMOTE_OS" = "nix" ]; then
|
||||
echo "$PROG: remote is *nix ($UNAME_OUT), using standard behavior" >&2
|
||||
|
||||
if [ "$DRY_RUN" = 1 ]; then
|
||||
echo "$PROG: [dry-run] would append $IDENTITY to ~/.ssh/authorized_keys on $TARGET" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
RESULT=$(ssh_run '
|
||||
set -e
|
||||
umask 077
|
||||
mkdir -p ~/.ssh
|
||||
tmpkey=$(mktemp ~/.ssh/.ssh-copy-id-win.XXXXXX)
|
||||
cat > "$tmpkey"
|
||||
touch ~/.ssh/authorized_keys
|
||||
chmod 600 ~/.ssh/authorized_keys
|
||||
if grep -qxF -f "$tmpkey" ~/.ssh/authorized_keys 2>/dev/null; then
|
||||
echo SSHCPID_EXISTS
|
||||
else
|
||||
cat "$tmpkey" >> ~/.ssh/authorized_keys
|
||||
echo SSHCPID_ADDED
|
||||
fi
|
||||
rm -f "$tmpkey"
|
||||
' < "$IDENTITY")
|
||||
|
||||
case "$RESULT" in
|
||||
*SSHCPID_ADDED*) echo "$PROG: key added." ;;
|
||||
*SSHCPID_EXISTS*) echo "$PROG: key already present, nothing to do." ;;
|
||||
*) echo "$PROG: remote command failed:" >&2; echo "$RESULT" >&2; exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ======================================================== Windows remote ==
|
||||
echo "$PROG: remote is Windows, using cmd.exe install path" >&2
|
||||
|
||||
# The key blob is the longest whitespace-separated field: type blob [comment].
|
||||
KEYBLOB=$(printf '%s\n' $PUBKEY_LINE | awk 'length($0) > length(m) { m = $0 } END { print m }')
|
||||
|
||||
# Everything below is injected unquoted into a cmd.exe command line, so it
|
||||
# must be free of characters cmd would treat as syntax.
|
||||
case "$KEYBLOB" in
|
||||
""|*[!A-Za-z0-9+/=]*)
|
||||
echo "$PROG: could not extract a usable base64 key blob from $IDENTITY" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
case "$PUBKEY_LINE" in
|
||||
*[\"%^\&\|\<\>\(\)!]*)
|
||||
echo "$PROG: key line contains characters unsafe for an unquoted cmd.exe command line" >&2
|
||||
echo "$PROG: (one of: \" % ^ & | < > ( ) !) — edit the key's comment and retry" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
|
||||
# ---------------------------------------------------------- admin or user
|
||||
MODE="$FORCE_MODE"
|
||||
if [ -z "$MODE" ]; then
|
||||
# S-1-5-32-544 = BUILTIN\Administrators, locale-independent.
|
||||
if ssh_run 'cmd.exe /c "whoami /groups | findstr /L S-1-5-32-544 >nul"' >/dev/null 2>&1; then
|
||||
MODE="admin"
|
||||
else
|
||||
MODE="user"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$MODE" = "admin" ]; then
|
||||
BASE_DIR='%ProgramData%'
|
||||
SUB_DIR='ssh'
|
||||
REL_FILE='ssh\administrators_authorized_keys'
|
||||
echo "$PROG: installing as Administrator key -> %ProgramData%\\$REL_FILE" >&2
|
||||
else
|
||||
BASE_DIR='%USERPROFILE%'
|
||||
SUB_DIR='.ssh'
|
||||
REL_FILE='.ssh\authorized_keys'
|
||||
echo "$PROG: installing as regular user key -> %USERPROFILE%\\$REL_FILE" >&2
|
||||
fi
|
||||
|
||||
# Single quote pair, none inside. See header comment.
|
||||
# The `if` MUST be parenthesised. Without parens, cmd swallows everything
|
||||
# after it as the if-body, so when the directory already exists the entire
|
||||
# rest of the chain is skipped and the command silently does nothing.
|
||||
INNER="cd /d $BASE_DIR & (if not exist $SUB_DIR mkdir $SUB_DIR) & findstr /L /C:$KEYBLOB $REL_FILE >nul 2>&1 && echo SSHCPID_EXISTS || ( (echo $PUBKEY_LINE)>>$REL_FILE & echo SSHCPID_ADDED )"
|
||||
|
||||
if [ "$DRY_RUN" = 1 ]; then
|
||||
echo "$PROG: [dry-run] mode=$MODE, would run on $TARGET:" >&2
|
||||
echo " cmd.exe /c \"$INNER\"" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
WIN_ERR=$(mktemp)
|
||||
RESULT=$(ssh_run "cmd.exe /c \"$INNER\"" 2>"$WIN_ERR")
|
||||
case "$RESULT" in
|
||||
*SSHCPID_ADDED*) echo "$PROG: key added." ;;
|
||||
*SSHCPID_EXISTS*) echo "$PROG: key already present, nothing to do." ;;
|
||||
*)
|
||||
echo "$PROG: remote command failed." >&2
|
||||
echo "$PROG: command was:" >&2
|
||||
echo " cmd.exe /c \"$INNER\"" >&2
|
||||
[ -s "$WIN_ERR" ] && { echo "$PROG: stderr:" >&2; cat "$WIN_ERR" >&2; }
|
||||
[ -n "$RESULT" ] && { echo "$PROG: stdout:" >&2; echo "$RESULT" >&2; }
|
||||
rm -f "$WIN_ERR"
|
||||
exit 1 ;;
|
||||
esac
|
||||
rm -f "$WIN_ERR"
|
||||
|
||||
if [ "$MODE" = "admin" ]; then
|
||||
# Windows sshd ignores administrators_authorized_keys unless only SYSTEM
|
||||
# and Administrators have access. SIDs keep this locale-independent.
|
||||
ACL_INNER="cd /d $BASE_DIR & icacls $REL_FILE /inheritance:r /grant *S-1-5-32-544:F /grant *S-1-5-18:F"
|
||||
if ACL_OUT=$(ssh_run "cmd.exe /c \"$ACL_INNER\"" 2>&1); then
|
||||
echo "$PROG: ACL set (SYSTEM + Administrators only)."
|
||||
else
|
||||
echo "$PROG: WARNING: could not set ACL on $REL_FILE — Windows will refuse the key until fixed:" >&2
|
||||
echo "$ACL_OUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
Reference in New Issue
Block a user