Repository navigation
Expand file tree
/
Copy pathssh.go
More file actions
524 lines (479 loc) · 20 KB
/
Copy pathssh.go
File metadata and controls
524 lines (479 loc) · 20 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
package main
import (
"bufio"
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"time"
"unicode/utf16"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
func dialSSH(user, host string, port int, password string, insecure bool) (*ssh.Client, error) {
var hostKeyCallback ssh.HostKeyCallback
var hostKeyAlgorithms []string
if insecure {
fmt.Println("=> WARNING: Host key verification is disabled. This is vulnerable to MITM attacks.")
hostKeyCallback = ssh.InsecureIgnoreHostKey()
} else {
var err error
hostKeyCallback, hostKeyAlgorithms, err = createHostKeyCallback(host, port)
if err != nil {
return nil, fmt.Errorf("host key setup failed: %w", err)
}
}
config := &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{
ssh.Password(password),
},
HostKeyCallback: hostKeyCallback,
HostKeyAlgorithms: hostKeyAlgorithms,
// Timeout は TCP dial 段階のみを縛る(ssh.Dial -> net.DialTimeout)。
// ハンドシェイク/認証段階は対象外だが、到達不能ホストの長時間ハングを防ぐ主目的には十分。
Timeout: 30 * time.Second,
}
addr := fmt.Sprintf("%s:%d", host, port)
client, err := ssh.Dial("tcp", addr, config)
if err != nil && len(hostKeyAlgorithms) > 0 && shouldRetryWithoutHostKeyAlgorithms(err) {
// HostKeyAlgorithmsの制限でハンドシェイクが失敗した場合のみ、
// 制限を外してリトライし、HostKeyCallbackのインタラクティブ更新に委ねる。
// 認証失敗等の場合は再試行せず、パスワードの再送によるロックアウトを避ける。
config.HostKeyAlgorithms = nil
return ssh.Dial("tcp", addr, config)
}
return client, err
}
// shouldRetryWithoutHostKeyAlgorithms はhost key algorithm制限を外して
// 再試行すべきエラーかを判定する。
// Go の x/crypto/ssh は鍵アルゴリズム交渉失敗時に sentinel error を返さず
// fmt.Errorf 由来の文字列を返すため、文字列ベースで判定する。
func shouldRetryWithoutHostKeyAlgorithms(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
if strings.Contains(msg, "unable to authenticate") {
return false
}
return strings.Contains(msg, "no common algorithm") && strings.Contains(msg, "host key")
}
// createHostKeyCallback はknown_hostsファイルを使用したホスト鍵検証コールバックを作成する。
// 未知のホストに対してはTOFU(Trust on First Use)でフィンガープリントを表示し、
// ユーザーの承認後にknown_hostsに追記する。
// 戻り値のhostKeyAlgorithmsは、known_hostsに登録済みの鍵アルゴリズム一覧。
// Go SSHクライアントのネゴシエーションをOpenSSHと同じ挙動に制限するために使用する。
func createHostKeyCallback(host string, port int) (ssh.HostKeyCallback, []string, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, nil, fmt.Errorf("cannot determine home directory: %w", err)
}
knownHostsPath := filepath.Join(home, ".ssh", "known_hosts")
// known_hostsファイルが存在しない場合は空ファイルを作成
if _, err := os.Stat(knownHostsPath); os.IsNotExist(err) {
sshDir := filepath.Dir(knownHostsPath)
if err := os.MkdirAll(sshDir, 0700); err != nil {
return nil, nil, fmt.Errorf("cannot create .ssh directory: %w", err)
}
if err := os.WriteFile(knownHostsPath, nil, 0600); err != nil {
return nil, nil, fmt.Errorf("cannot create known_hosts file: %w", err)
}
}
cb, err := knownhosts.New(knownHostsPath)
if err != nil {
return nil, nil, fmt.Errorf("cannot read known_hosts: %w", err)
}
addr := knownhosts.Normalize(fmt.Sprintf("%s:%d", host, port))
// known_hostsから対象ホストの鍵アルゴリズムを取得。
// OpenSSHはknown_hostsにあるアルゴリズムだけをネゴシエーションするが、
// Goのknownhostsライブラリはこれを行わないため、自前で制限する。
hostKeyAlgorithms := hostKeyAlgorithmsFromKnownHosts(knownHostsPath, addr)
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
err := cb(hostname, remote, key)
if err == nil {
return nil
}
var keyErr *knownhosts.KeyError
if !errors.As(err, &keyErr) {
return err
}
if len(keyErr.Want) > 0 {
// ホスト鍵が変更されている — MITM攻撃の可能性
fmt.Println("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
fmt.Println("@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @")
fmt.Println("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
fmt.Println("IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!")
fmt.Println("Someone could be eavesdropping on you right now (man-in-the-middle attack)!")
fmt.Println("It is also possible that a host key has just been changed.")
fmt.Printf("The fingerprint for the %s key sent by the remote host is\n%s.\n",
key.Type(), ssh.FingerprintSHA256(key))
fmt.Printf("Do you want to update your known_hosts file (%s)? (yes/no) ", knownHostsPath)
answer, err := readLineFromTerminal()
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
if answer != "yes" {
return fmt.Errorf("host key verification failed")
}
// known_hostsから古いエントリを除去して新しい鍵を追記
if err := replaceHostKeyInKnownHosts(knownHostsPath, addr, key); err != nil {
return fmt.Errorf("failed to update known_hosts: %w", err)
}
fmt.Printf("Warning: Updated host key for '%s' in known_hosts.\n", addr)
return nil
}
// 未知のホスト — TOFUプロンプト
fmt.Printf("The authenticity of host '%s (%s)' can't be established.\n",
hostname, remote.String())
fmt.Printf("%s key fingerprint is %s.\n", key.Type(), ssh.FingerprintSHA256(key))
fmt.Print("Are you sure you want to continue connecting (yes/no)? ")
// 端末から直接読み取る(パイプ入力と分離)
answer, err := readLineFromTerminal()
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
if answer != "yes" {
return fmt.Errorf("host key verification failed")
}
// known_hostsに追記(既存ファイルがハッシュ形式ならハッシュ化して追記)
hostEntry := addr
if knownHostsHasHashedEntries(knownHostsPath) {
hostEntry = knownhosts.HashHostname(addr)
}
line := knownhosts.Line([]string{hostEntry}, key)
if err := appendKnownHostsLine(knownHostsPath, line); err != nil {
return err
}
fmt.Printf("Warning: Permanently added '%s' to the list of known hosts.\n", addr)
return nil
}, hostKeyAlgorithms, nil
}
// knownHostsHasHashedEntries はknown_hostsファイルにハッシュ化エントリが存在するかを返す。
func knownHostsHasHashedEntries(knownHostsPath string) bool {
data, err := os.ReadFile(knownHostsPath)
if err != nil {
return false
}
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if trimmed != "" && strings.HasPrefix(trimmed, "|1|") {
return true
}
}
return false
}
// matchHashedHost はハッシュ化されたknown_hostsエントリ(|1|<salt>|<hash>)が
// 指定アドレスにマッチするかをHMAC-SHA1で検証する。
func matchHashedHost(pattern, addr string) bool {
// フォーマット: |1|<base64-salt>|<base64-hash>
parts := strings.Split(pattern, "|")
if len(parts) != 4 || parts[0] != "" || parts[1] != "1" {
return false
}
salt, err := base64.StdEncoding.DecodeString(parts[2])
if err != nil {
return false
}
expectedHash, err := base64.StdEncoding.DecodeString(parts[3])
if err != nil {
return false
}
mac := hmac.New(sha1.New, salt)
mac.Write([]byte(addr))
return hmac.Equal(mac.Sum(nil), expectedHash)
}
// hostMatchesAddr はknown_hostsのホストフィールド(plain-textまたはハッシュ形式)が
// 指定アドレスに**完全一致**するかを判定する。
//
// ワイルドカードは意図的に解釈しない。この関数の利用者は
// replaceHostKeyInKnownHosts(=マッチした行を known_hosts から削除する経路)であり、
// ここでパターンを展開すると `192.168.1.*` のような 1 行が他ホストの鍵も巻き添えに消える。
// 読み取り専用の照合(hostKeyAlgorithmsFromKnownHosts)は
// knownHostsLineMatchesAddr を使い、OpenSSH と同じパターン解釈を行う。
//
// 大文字小文字も畳まない(hostPatternMatch とは非対称)。この関数が走るのは
// x/crypto/ssh/knownhosts のコールバックが「鍵が変わった」と判定した後だけで、
// そのコールバック自身がバイト完全一致(同パッケージに大小文字を畳む処理は無い)。
// ここだけ広げると、コールバックが一致させていない行まで削除対象になる。
func hostMatchesAddr(host, addr string) bool {
if strings.HasPrefix(host, "|") {
return matchHashedHost(host, addr)
}
return host == addr
}
// lowerASCII は ASCII 大文字 1 バイトを小文字に畳む。
// known_hosts のホストフィールドは ASCII(ホスト名 / IP / `[addr]:port`)なので
// Unicode 対応(strings.EqualFold)は不要で、バイト単位で足りる。
func lowerASCII(b byte) byte {
if 'A' <= b && b <= 'Z' {
return b + ('a' - 'A')
}
return b
}
// hostPatternMatch は OpenSSH の known_hosts ホストパターン(`*` = 0 文字以上、
// `?` = 任意の 1 文字)が addr にマッチするかを判定する。
// known_hosts のホストフィールドは ASCII(ホスト名 / IP / `[addr]:port`)なので
// バイト単位で比較する。バックトラックは `*` の位置を 1 つ覚えるだけの線形スキャンで足りる。
//
// 比較は**大文字小文字を区別しない**。OpenSSH の match_hostname() はホスト名と
// パターンの両方を lowercase してから照合するため、区別するとパターン行が
// 取りこぼされる(`*.Example.COM` が win.example.com に当たらない)か、
// 否定が効かなくなる(`!WIN.example.com` が win.example.com を除外できない)。
// どちらもこの関数が解決しようとしているワイルドカード誤警告をそのまま残してしまう。
func hostPatternMatch(pattern, addr string) bool {
pi, ai := 0, 0
star, starMatch := -1, 0
for ai < len(addr) {
switch {
case pi < len(pattern) && (pattern[pi] == '?' || lowerASCII(pattern[pi]) == lowerASCII(addr[ai])):
pi++
ai++
case pi < len(pattern) && pattern[pi] == '*':
star, starMatch = pi, ai
pi++
case star >= 0:
// 直前の `*` に 1 文字余計に食わせてやり直す
starMatch++
pi, ai = star+1, starMatch
default:
return false
}
}
for pi < len(pattern) && pattern[pi] == '*' {
pi++
}
return pi == len(pattern)
}
// knownHostsLineMatchesAddr は known_hosts 行のホストフィールド(カンマ区切り)が
// addr に適用されるかを OpenSSH と同じ規則で判定する。
// - ハッシュ化エントリ(|1|salt|hash)は HMAC で照合する(ワイルドカードは持てない)
// - plain-text エントリは `*` / `?` のワイルドカードを解釈する
// - `!pattern` の否定が 1 つでもマッチしたら、その行は addr に適用されない
// (順序に関係なく否定が勝つため、肯定一致で早期 return してはならない)
func knownHostsLineMatchesAddr(hostField, addr string) bool {
matched := false
for _, h := range strings.Split(hostField, ",") {
h = strings.TrimSpace(h)
if h == "" {
continue
}
if negated, ok := strings.CutPrefix(h, "!"); ok {
if hostPatternMatch(negated, addr) {
return false
}
continue
}
if strings.HasPrefix(h, "|") {
if matchHashedHost(h, addr) {
matched = true
}
continue
}
if hostPatternMatch(h, addr) {
matched = true
}
}
return matched
}
// hostKeyAlgorithmsFromKnownHosts はknown_hostsファイルから対象ホストの鍵アルゴリズム一覧を返す。
// ホストが未登録の場合はnilを返し、SSHクライアントのデフォルト動作に委ねる。
//
// ホスト照合は knownHostsLineMatchesAddr(OpenSSH 準拠のワイルドカード+否定)で行う。
// 完全一致だけで見ていると `192.168.1.*` のようなパターン行が「未登録」と判定され、
// 制限なしでネゴシエーションした結果 known_hosts に載っていない種別の鍵が選ばれ、
// 実際には何も変わっていないホストに対して「HOST IDENTIFICATION HAS CHANGED」を
// 誤表示する(knownhosts コールバック側はパターンを解釈するため食い違う)。
//
// `@cert-authority` / `@revoked` のマーカー行は対象外にする。前者が持つのは CA 鍵の種別で
// あってホスト鍵の種別ではなく、これを制限リストに混ぜると証明書ホストへの接続を壊す。
// マーカー行しか無いホストでは nil(=制限なし)を返すのが安全側。
func hostKeyAlgorithmsFromKnownHosts(knownHostsPath string, addr string) []string {
data, err := os.ReadFile(knownHostsPath)
if err != nil {
return nil
}
var algorithms []string
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "@") {
continue
}
fields := strings.Fields(trimmed)
if len(fields) < 3 {
continue
}
if knownHostsLineMatchesAddr(fields[0], addr) {
algorithms = append(algorithms, fields[1])
}
}
return algorithms
}
// replaceHostKeyInKnownHosts はknown_hostsファイルから指定ホストの古いエントリを除去し、
// 新しいホスト鍵を追記する。
func replaceHostKeyInKnownHosts(knownHostsPath string, addr string, newKey ssh.PublicKey) error {
data, err := os.ReadFile(knownHostsPath)
if err != nil {
return fmt.Errorf("cannot read known_hosts: %w", err)
}
var kept []string
hasHashed := false
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
kept = append(kept, line)
continue
}
// 行の先頭フィールド(カンマ区切りのホスト一覧)をチェック
fields := strings.Fields(trimmed)
if len(fields) < 2 {
kept = append(kept, line)
continue
}
hosts := strings.Split(fields[0], ",")
var remaining []string
matched := false
for _, h := range hosts {
if strings.HasPrefix(h, "|") {
hasHashed = true
}
if hostMatchesAddr(h, addr) {
matched = true
} else {
remaining = append(remaining, h)
}
}
if !matched {
// addrにマッチしない行 — そのまま保持
kept = append(kept, line)
} else if len(remaining) > 0 {
// 他のエイリアスが残っている — addrだけ除去してフィールドから再構成
fields[0] = strings.Join(remaining, ",")
kept = append(kept, strings.Join(fields, " "))
}
// remaining が空の場合は行ごと削除(対象ホストのみの行)
}
// 末尾の空行を整理して書き戻す
content := strings.Join(kept, "\n")
if !strings.HasSuffix(content, "\n") {
content += "\n"
}
// 新しいエントリを追記(ファイル内にハッシュ形式エントリがあればハッシュ化して追記)
var hostEntry []string
if hasHashed {
hostEntry = []string{knownhosts.HashHostname(addr)}
} else {
hostEntry = []string{addr}
}
line := knownhosts.Line(hostEntry, newKey)
content += line + "\n"
// truncate+write は部分書き込みで known_hosts を破損させ得るため、
// 同一ディレクトリの temp に書いて rename する(OpenSSH 自身の known_hosts 更新と同方式)。
// 読み手は常に「旧 or 新の完全なファイル」のみを見る。
// 残る race は同時 yes 時の lost-update(片方の追記が消える)だが、これは破損ではなく
// 次回 TOFU で自己修復する良性のため受容する。TOFU の追記経路は O_APPEND で原子的。
if err := atomicWriteFile(knownHostsPath, []byte(content), 0600); err != nil {
return fmt.Errorf("cannot write known_hosts: %w", err)
}
return nil
}
// atomicWriteFile は同一ディレクトリの一時ファイルに書き込んでから rename することで、
// 書き込み途中のクラッシュや同時アクセスによるファイル破損を防ぐ。
// temp は必ず宛先と同じディレクトリに作る(別ファイルシステムだと rename が非原子になる)。
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".known_hosts-*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName) // rename 成功後は no-op、失敗時は後始末
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Chmod(perm); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}
// appendKnownHostsLine は known_hosts に1エントリを追記する。
// 既存ファイルが末尾改行で終わっていない場合は先に改行を補い、新エントリが
// 直前の行に連結して known_hosts が破損するのを防ぐ(authorized_keys 追記と同じ不変条件)。
func appendKnownHostsLine(path, line string) error {
var leadingNL string
if data, err := os.ReadFile(path); err == nil && len(data) > 0 && data[len(data)-1] != '\n' {
leadingNL = "\n"
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("cannot write to known_hosts: %w", err)
}
defer f.Close()
if _, err := fmt.Fprintf(f, "%s%s\n", leadingNL, line); err != nil {
return fmt.Errorf("cannot write to known_hosts: %w", err)
}
return nil
}
// readLineFromTerminal は端末から1行読み取る。
// パスワード入力後にStdinがパイプ化されている場合でも/dev/ttyから直接読み取る。
// rawモードは使用せず、行バッファリングを利用してバックスペース等の行編集を有効にする。
//
// テストからホスト鍵検証のプロンプト応答を注入できるよう var にしている。
// 差し替えるテストは t.Parallel() を使わず、defer で必ず元へ戻すこと。
var readLineFromTerminal = func() (string, error) {
tty, err := os.Open("/dev/tty")
if err != nil {
// /dev/ttyが使えない場合(Windows等)はStdinにフォールバック
return readLine(os.Stdin)
}
defer tty.Close()
return readLine(tty)
}
// readLine はリーダーから改行までの1行を読み取る。
func readLine(r *os.File) (string, error) {
reader := bufio.NewReader(r)
line, err := reader.ReadString('\n')
if err != nil && len(line) == 0 {
return "", err
}
return strings.TrimRight(line, "\r\n"), nil
}
// encodePowerShellCommand はPowerShellスクリプトをUTF-16LEのBase64にエンコードする。
// -EncodedCommand で使用することで、シェルエスケープ問題を完全に回避する。
func encodePowerShellCommand(script string) string {
runes := utf16.Encode([]rune(script))
bytes := make([]byte, len(runes)*2)
for i, r := range runes {
bytes[i*2] = byte(r)
bytes[i*2+1] = byte(r >> 8)
}
return base64.StdEncoding.EncodeToString(bytes)
}
// runRemotePowerShell はPowerShellスクリプトを-EncodedCommand経由で安全に実行する。
func runRemotePowerShell(client *ssh.Client, script string) (string, error) {
encoded := encodePowerShellCommand(script)
command := fmt.Sprintf("powershell -NoProfile -EncodedCommand %s", encoded)
return runRemoteCommand(client, command)
}
func runRemoteCommand(client *ssh.Client, command string) (string, error) {
session, err := client.NewSession()
if err != nil {
return "", fmt.Errorf("failed to create session: %w", err)
}
defer session.Close()
output, err := session.CombinedOutput(command)
if err != nil {
return string(output), fmt.Errorf("command execution failed: %w\noutput: %s", err, output)
}
return string(output), nil
}