| 1 | #!/usr/bin/env bash |
| 2 | # |
| 3 | # ssh-copy-id-win — ssh-copy-id that also works against Windows OpenSSH |
| 4 | # servers (regular users and Administrators), while keeping full *nix |
| 5 | # compatibility. |
| 6 | # |
| 7 | # Windows design notes: |
| 8 | # |
| 9 | # * cmd.exe only. Every remote command is dispatched as |
| 10 | # cmd.exe /c "<inner>" |
| 11 | # so it does not matter what sshd has configured as DefaultShell. |
| 12 | # |
| 13 | # * ZERO quotes inside <inner>. This is deliberate and load-bearing: |
| 14 | # if the remote DefaultShell is PowerShell, sshd runs |
| 15 | # powershell -c "cmd.exe /c \"<inner>\"" |
| 16 | # and PowerShell re-escapes nested quotes as \" — which cmd.exe does |
| 17 | # not understand. With exactly one quote pair (the outer one) the |
| 18 | # string survives both shells identically. To achieve that: |
| 19 | # - `cd /d %USERPROFILE%` first, then use short relative paths that |
| 20 | # cannot contain spaces (.ssh\authorized_keys). `cd` consumes the |
| 21 | # rest of its argument, so an unquoted path with spaces is fine. |
| 22 | # - search on the base64 key blob only, which is whitespace-free. |
| 23 | # |
| 24 | # * Appends with `(echo LINE)>>file`, never `echo LINE>>file && ...`. |
| 25 | # In the latter, cmd lifts the redirect out of the middle and the |
| 26 | # space before `&&` ends up INSIDE the echoed text, producing a |
| 27 | # trailing space. That breaks any later exact-match dedup and silently |
| 28 | # appends a duplicate key on every run. |
| 29 | # |
| 30 | # * No hard-coded drive letters: %USERPROFILE% / %ProgramData% only. |
| 31 | # |
| 32 | # * Administrators are written to |
| 33 | # %ProgramData%\ssh\administrators_authorized_keys, because the stock |
| 34 | # Windows sshd_config has a `Match Group administrators` block that |
| 35 | # makes that file the ONLY one consulted for admin accounts. The |
| 36 | # required restrictive ACL (SYSTEM + Administrators) is applied after. |
| 37 | |
| 38 | set -u |
| 39 | |
| 40 | PROG=$(basename "$0") |
| 41 | IDENTITY="" |
| 42 | PORT="" |
| 43 | SSH_OPTS=() |
| 44 | FORCE_MODE="" # "" = autodetect, "admin", "user" |
| 45 | DRY_RUN=0 |
| 46 | |
| 47 | usage() { |
| 48 | cat <<EOF |
| 49 | Usage: $PROG [-i identity_file] [-p port] [-o ssh_option] [-A|-U] [-n] [user@]hostname |
| 50 | |
| 51 | -i identity_file Public key to install. Defaults to the most recently |
| 52 | modified ~/.ssh/id*.pub (same rule as ssh-copy-id). |
| 53 | -p port Remote SSH port. |
| 54 | -o ssh_option Extra option passed through to ssh (repeatable). |
| 55 | -A Force the Administrator path on Windows. |
| 56 | -U Force the regular-user path on Windows. |
| 57 | -n Dry run: print what would happen, change nothing. |
| 58 | -h This help. |
| 59 | EOF |
| 60 | } |
| 61 | |
| 62 | while getopts ":i:p:o:AUnh" opt; do |
| 63 | case "$opt" in |
| 64 | i) IDENTITY=$OPTARG ;; |
| 65 | p) PORT=$OPTARG ;; |
| 66 | o) SSH_OPTS+=(-o "$OPTARG") ;; |
| 67 | A) FORCE_MODE="admin" ;; |
| 68 | U) FORCE_MODE="user" ;; |
| 69 | n) DRY_RUN=1 ;; |
| 70 | h) usage; exit 0 ;; |
| 71 | \?) echo "$PROG: unknown option -$OPTARG" >&2; usage >&2; exit 1 ;; |
| 72 | :) echo "$PROG: option -$OPTARG requires an argument" >&2; exit 1 ;; |
| 73 | esac |
| 74 | done |
| 75 | shift $((OPTIND - 1)) |
| 76 | |
| 77 | TARGET=${1:-} |
| 78 | if [ -z "$TARGET" ]; then |
| 79 | echo "$PROG: missing [user@]hostname" >&2 |
| 80 | usage >&2 |
| 81 | exit 1 |
| 82 | fi |
| 83 | |
| 84 | PORT_OPTS=() |
| 85 | [ -n "$PORT" ] && PORT_OPTS=(-p "$PORT") |
| 86 | |
| 87 | # This script makes several round trips (probe OS, detect admin, install, |
| 88 | # set ACL). On a password-auth host that would mean re-entering the password |
| 89 | # for each one, so all of them share a single multiplexed connection: you |
| 90 | # authenticate once and the rest ride along. |
| 91 | CTL_DIR=$(mktemp -d "${TMPDIR:-/tmp}/ssh-copy-id-win.XXXXXX") |
| 92 | CTL_PATH="$CTL_DIR/s" |
| 93 | |
| 94 | cleanup() { |
| 95 | ssh -o ControlPath="$CTL_PATH" -O exit "$TARGET" >/dev/null 2>&1 |
| 96 | rm -rf "$CTL_DIR" |
| 97 | } |
| 98 | trap cleanup EXIT INT TERM |
| 99 | |
| 100 | ssh_run() { |
| 101 | # LogLevel=ERROR keeps banners/warnings (e.g. the post-quantum notice) out |
| 102 | # of the output we parse for result markers. |
| 103 | ssh -o LogLevel=ERROR \ |
| 104 | -o ControlMaster=auto -o ControlPath="$CTL_PATH" -o ControlPersist=60 \ |
| 105 | -o PreferredAuthentications=publickey,keyboard-interactive,password \ |
| 106 | "${PORT_OPTS[@]}" "${SSH_OPTS[@]}" "$TARGET" "$@" |
| 107 | } |
| 108 | |
| 109 | # ---------------------------------------------------------------- identity |
| 110 | if [ -z "$IDENTITY" ]; then |
| 111 | IDENTITY=$(ls -dt "$HOME"/.ssh/id*.pub 2>/dev/null | grep -v -- '-cert\.pub$' | head -n 1) |
| 112 | [ -n "$IDENTITY" ] || { echo "$PROG: no identity found and none given with -i" >&2; exit 1; } |
| 113 | else |
| 114 | case "$IDENTITY" in |
| 115 | *.pub) : ;; |
| 116 | *) [ -f "${IDENTITY}.pub" ] && IDENTITY="${IDENTITY}.pub" ;; |
| 117 | esac |
| 118 | fi |
| 119 | [ -f "$IDENTITY" ] || { echo "$PROG: identity file not found: $IDENTITY" >&2; exit 1; } |
| 120 | |
| 121 | # First non-blank line, CR stripped, whitespace collapsed. |
| 122 | PUBKEY_LINE=$(tr -d '\r' < "$IDENTITY" | grep -v '^[[:space:]]*$' | head -n 1 \ |
| 123 | | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/[[:space:]][[:space:]]*/ /g') |
| 124 | [ -n "$PUBKEY_LINE" ] || { echo "$PROG: $IDENTITY contains no key" >&2; exit 1; } |
| 125 | |
| 126 | echo "$PROG: using identity: $IDENTITY" >&2 |
| 127 | |
| 128 | # ------------------------------------------------------------ OS detection |
| 129 | # Deliberately explicit: a failed connection must NOT be mistaken for |
| 130 | # "not *nix, therefore Windows". |
| 131 | PROBE_ERR="$CTL_DIR/probe.err" |
| 132 | UNAME_OUT=$(ssh_run uname -s 2>"$PROBE_ERR") |
| 133 | SSH_RC=$? |
| 134 | |
| 135 | if [ $SSH_RC -eq 255 ]; then |
| 136 | echo "$PROG: cannot connect to $TARGET" >&2 |
| 137 | [ -s "$PROBE_ERR" ] && sed "s/^/$PROG: ssh: /" "$PROBE_ERR" >&2 |
| 138 | echo "$PROG: (if this host uses password auth, run from an interactive" >&2 |
| 139 | echo "$PROG: terminal so ssh can prompt for it)" >&2 |
| 140 | exit 1 |
| 141 | fi |
| 142 | |
| 143 | REMOTE_OS="" |
| 144 | case "$UNAME_OUT" in |
| 145 | *Linux*|*Darwin*|*BSD*|*SunOS*|*AIX*|*CYGWIN*|*MINGW*|*MSYS*) REMOTE_OS="nix" ;; |
| 146 | esac |
| 147 | |
| 148 | if [ -z "$REMOTE_OS" ]; then |
| 149 | # Probe for Windows rather than assuming it. |
| 150 | VER_OUT=$(ssh_run 'cmd.exe /c ver' 2>/dev/null) |
| 151 | case "$VER_OUT" in |
| 152 | *Windows*|*windows*) REMOTE_OS="windows" ;; |
| 153 | esac |
| 154 | fi |
| 155 | |
| 156 | if [ -z "$REMOTE_OS" ]; then |
| 157 | echo "$PROG: could not determine remote OS (uname gave '$UNAME_OUT')" >&2 |
| 158 | exit 1 |
| 159 | fi |
| 160 | |
| 161 | # =========================================================== *nix remote == |
| 162 | if [ "$REMOTE_OS" = "nix" ]; then |
| 163 | echo "$PROG: remote is *nix ($UNAME_OUT), using standard behavior" >&2 |
| 164 | |
| 165 | if [ "$DRY_RUN" = 1 ]; then |
| 166 | echo "$PROG: [dry-run] would append $IDENTITY to ~/.ssh/authorized_keys on $TARGET" >&2 |
| 167 | exit 0 |
| 168 | fi |
| 169 | |
| 170 | RESULT=$(ssh_run ' |
| 171 | set -e |
| 172 | umask 077 |
| 173 | mkdir -p ~/.ssh |
| 174 | tmpkey=$(mktemp ~/.ssh/.ssh-copy-id-win.XXXXXX) |
| 175 | cat > "$tmpkey" |
| 176 | touch ~/.ssh/authorized_keys |
| 177 | chmod 600 ~/.ssh/authorized_keys |
| 178 | if grep -qxF -f "$tmpkey" ~/.ssh/authorized_keys 2>/dev/null; then |
| 179 | echo SSHCPID_EXISTS |
| 180 | else |
| 181 | cat "$tmpkey" >> ~/.ssh/authorized_keys |
| 182 | echo SSHCPID_ADDED |
| 183 | fi |
| 184 | rm -f "$tmpkey" |
| 185 | ' < "$IDENTITY") |
| 186 | |
| 187 | case "$RESULT" in |
| 188 | *SSHCPID_ADDED*) echo "$PROG: key added." ;; |
| 189 | *SSHCPID_EXISTS*) echo "$PROG: key already present, nothing to do." ;; |
| 190 | *) echo "$PROG: remote command failed:" >&2; echo "$RESULT" >&2; exit 1 ;; |
| 191 | esac |
| 192 | exit 0 |
| 193 | fi |
| 194 | |
| 195 | # ======================================================== Windows remote == |
| 196 | echo "$PROG: remote is Windows, using cmd.exe install path" >&2 |
| 197 | |
| 198 | # The key blob is the longest whitespace-separated field: type blob [comment]. |
| 199 | KEYBLOB=$(printf '%s\n' $PUBKEY_LINE | awk 'length($0) > length(m) { m = $0 } END { print m }') |
| 200 | |
| 201 | # Everything below is injected unquoted into a cmd.exe command line, so it |
| 202 | # must be free of characters cmd would treat as syntax. |
| 203 | case "$KEYBLOB" in |
| 204 | ""|*[!A-Za-z0-9+/=]*) |
| 205 | echo "$PROG: could not extract a usable base64 key blob from $IDENTITY" >&2 |
| 206 | exit 1 ;; |
| 207 | esac |
| 208 | case "$PUBKEY_LINE" in |
| 209 | *[\"%^\&\|\<\>\(\)!]*) |
| 210 | echo "$PROG: key line contains characters unsafe for an unquoted cmd.exe command line" >&2 |
| 211 | echo "$PROG: (one of: \" % ^ & | < > ( ) !) — edit the key's comment and retry" >&2 |
| 212 | exit 1 ;; |
| 213 | esac |
| 214 | |
| 215 | # ---------------------------------------------------------- admin or user |
| 216 | MODE="$FORCE_MODE" |
| 217 | if [ -z "$MODE" ]; then |
| 218 | # S-1-5-32-544 = BUILTIN\Administrators, locale-independent. |
| 219 | if ssh_run 'cmd.exe /c "whoami /groups | findstr /L S-1-5-32-544 >nul"' >/dev/null 2>&1; then |
| 220 | MODE="admin" |
| 221 | else |
| 222 | MODE="user" |
| 223 | fi |
| 224 | fi |
| 225 | |
| 226 | if [ "$MODE" = "admin" ]; then |
| 227 | BASE_DIR='%ProgramData%' |
| 228 | SUB_DIR='ssh' |
| 229 | REL_FILE='ssh\administrators_authorized_keys' |
| 230 | echo "$PROG: installing as Administrator key -> %ProgramData%\\$REL_FILE" >&2 |
| 231 | else |
| 232 | BASE_DIR='%USERPROFILE%' |
| 233 | SUB_DIR='.ssh' |
| 234 | REL_FILE='.ssh\authorized_keys' |
| 235 | echo "$PROG: installing as regular user key -> %USERPROFILE%\\$REL_FILE" >&2 |
| 236 | fi |
| 237 | |
| 238 | # Single quote pair, none inside. See header comment. |
| 239 | # The `if` MUST be parenthesised. Without parens, cmd swallows everything |
| 240 | # after it as the if-body, so when the directory already exists the entire |
| 241 | # rest of the chain is skipped and the command silently does nothing. |
| 242 | 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 )" |
| 243 | |
| 244 | if [ "$DRY_RUN" = 1 ]; then |
| 245 | echo "$PROG: [dry-run] mode=$MODE, would run on $TARGET:" >&2 |
| 246 | echo " cmd.exe /c \"$INNER\"" >&2 |
| 247 | exit 0 |
| 248 | fi |
| 249 | |
| 250 | WIN_ERR=$(mktemp) |
| 251 | RESULT=$(ssh_run "cmd.exe /c \"$INNER\"" 2>"$WIN_ERR") |
| 252 | case "$RESULT" in |
| 253 | *SSHCPID_ADDED*) echo "$PROG: key added." ;; |
| 254 | *SSHCPID_EXISTS*) echo "$PROG: key already present, nothing to do." ;; |
| 255 | *) |
| 256 | echo "$PROG: remote command failed." >&2 |
| 257 | echo "$PROG: command was:" >&2 |
| 258 | echo " cmd.exe /c \"$INNER\"" >&2 |
| 259 | [ -s "$WIN_ERR" ] && { echo "$PROG: stderr:" >&2; cat "$WIN_ERR" >&2; } |
| 260 | [ -n "$RESULT" ] && { echo "$PROG: stdout:" >&2; echo "$RESULT" >&2; } |
| 261 | rm -f "$WIN_ERR" |
| 262 | exit 1 ;; |
| 263 | esac |
| 264 | rm -f "$WIN_ERR" |
| 265 | |
| 266 | if [ "$MODE" = "admin" ]; then |
| 267 | # Windows sshd ignores administrators_authorized_keys unless only SYSTEM |
| 268 | # and Administrators have access. SIDs keep this locale-independent. |
| 269 | ACL_INNER="cd /d $BASE_DIR & icacls $REL_FILE /inheritance:r /grant *S-1-5-32-544:F /grant *S-1-5-18:F" |
| 270 | if ACL_OUT=$(ssh_run "cmd.exe /c \"$ACL_INNER\"" 2>&1); then |
| 271 | echo "$PROG: ACL set (SYSTEM + Administrators only)." |
| 272 | else |
| 273 | echo "$PROG: WARNING: could not set ACL on $REL_FILE — Windows will refuse the key until fixed:" >&2 |
| 274 | echo "$ACL_OUT" >&2 |
| 275 | exit 1 |
| 276 | fi |
| 277 | fi |
| 278 |
ergosteur / ssh-copy-id-win
Last active 1 month ago
ssh-copy-id script for Linux that works with remote Windows hosts