ロリポップ ssh に PUTTY 使うのがさすがに面倒になってきて

ひとさまの役に立つような立たないようなネタ。だけれどもちょっと長いこと大作を書いてなかったので、気分的に大作。(でもネタとしては下らない。)

前置き

コマンドラインからの PUTTY 使いを楽にするために Python でラッパーを書きたい、つー話。

動機

タイトルの通りなんだけれどもね、「ロリポップ!」、というよりか、「レンタルサーバの宿命」にちょいと関係している。

ロリポップ! の ssh アカウントってこんな感じなの:

  • サーバ名(ホスト名)は ssh999.lolipop.jp のようなもの。
  • ユーザ名(アカウント名)は yourdomain-yourname のようなもの。
  • ポート番号が ssh のデフォルトの 22 じゃない
  • パスワードは自動生成で「ZjJjZTFlOTg5YjI3MmNmY2I2Nzc2MTY3」のようなもの。自分では選べない。

ついでに。「PUTTY」の pros, cons:

  • PROS
    • なんだかんだで Windows ではいまだに導入の敷居が低いほう
    • 割と動作が軽い
    • シンプル
    • コマンドラインからの使用がそんなに面倒ぢゃない
  • CONS
    • CLI としては (POSIX 的 / FSF 的にみて) ダサい
    • 要するに CLI は引数順依存やらで時々悩む

「ssh」としてなんで TeraTerm じゃないのかは「動作の軽さ」と「シンプルさ」で PUTTY に劣るから。TeraTerm も本格的にリモート操作するには結構欠かせないけれど、(おバカな collector のこともあって) 積極的に日常使いにするほど好きにはなれない。

「scp」としてなんで WinSCP じゃないのかは、これは「WinSCP じゃない」というわけではなくて、WinSCP も良く使うんだけれども、その瞬間での作業状態によるのね。コマンドライン主体での作業中には「よっこらしょ」とスタートメニューから WinSCP を起動するのがダルい場合もある。「そこにものがあり今そこにいる (cwd にブツがありそこに cd している)」ならコマンドラインからやりたい。ましてや MSYS な bash ユーザである。ヒストリから呼び出せれば楽ではないの。

さて。今問題なのは、「ssh 公開鍵認証をお膳立てしていないならば、コマンドラインから使うには毎度この複雑 (で記憶できない) アカウントの情報を打ち込む必要がある」ということだ。かといって、スクリプトにパスワードを剥き身で書き込んでおくのは気持ち悪い。そうするくらいなら暗号化して「毎度自分の選んだ記憶しやすいパスワードを入力したい」

てこと。

お注意だら

  1. フツーは公開鍵認証をトライすべきよ。なのでそれを検討したことがないなら、こんな記事読まずにそっちをやりなはれ。
  2. PUTTY も GUI なら「保存済みセッション (saved session)」の機能があるんだからさ、それでいいならそれでいいんだよ。
  3. WinSCP, TeraTerm で満足してるならあえてこんな記事読む必要なんかないのです

この記事の数少ない価値

この記事で導き出す結論となる道具は、ほとんどの皆には役に立たないものです。

けどね、「ワタシの真似をする」つもりがない場合に限れば、その途中経過のお膳立てや思考は、割と一般的なタスクとして面白いこともあるかと思う。気軽に読むつもりなら読み進めてみて。

本題

必須の道具と愚痴

「パスワードを剥き身でストアしたくない」というニーズが自作のアプリケーションと localhost に閉じているならば、これは一方向ハッシュで済む。だからこれは標準 Python の世界だけで問題なく実現出来る。

けれども今はそうではない。「ssh パスワード認証に必要なパスワード」は、ssh サーバとの接続時点では「平文」になっていなければならない。なので「盆に還せない覆水 (一方向ハッシュ)」はニーズに合わない。要するに復号出来なければならない。

Python の「電池付き」というポリシーにはいつも助けられているんだけれど、「暗号化」はこれの対象外、なんだよねぇ。一つくらいシンプルなのを入れといてくれてもいいのに、なんてことも思うんだけれど、まぁ標準バンドルが難しいゾーンだというのもわからないでもない。まぁ仕方がない。

というわけで「まともな暗号化パッケージ」を見繕う必要がある。おそらく一番メジャーなのが pycrypto であろうと思う。暗号関連のサービスでずっと昔から直接紹介されてきたしね。

ただこの子、Windows 版のインストーラや wheel が公式に配布されてないのよ…。ワタシのように Microsoft Visual C++ Compiler for Python 2.7 (Python 2.7 の場合) 環境やらのビルド環境を整えてあればなんてことはないんだけどね、そうしてない人はそこから始めねばならぬ。こいつぁちとキビシイ、かもしれない。

環境があればソースをダウンロードしてきて python setup.py install だけ。それだけ。だから環境があれば全然簡単。

アルゴリズム選択と、何を秘匿してどうストアしとくのさ、な話

暗号化という技術で「利用者として」難しいのは無論その「暗号化理論」なんかではなくて。「暗号化理論」なんぞは専門家に任せなはれ。生兵法は大怪我のもと、もしくは民間療法で命を落とすなかれ、的な。

暗号化技術の利用で考えなければならないのは、暗号強度や PROS/CONS のことももちろんなんだけれど、「専門家でない普通の技術者に責務が 100% ある」のが、「秘密の格納場所と移送手段の検討」なのね。

一番わかりやすいおバカ (で現実にそれをやらかす初心者も実際に多い) な「それ、隠してないぞ」は、URL にパスワードが剥き身、ってヤツね。「https://some.server.com/hogecgi.cgi?password=Himitsu」みたいなね。笑ってるそこのあなた。これ、冗談などではないのよ。本当にこれをやってしまうエンジニアがいるんです。(そして SSL だからセキュアだ、と安心する、というオマケつき。)

さて、今 pycrypto で使える暗号化で考える。実際今のニーズにはそんなに厳重である必要もなくて、だって「スクリプトにパスワードを平文で直書きするよりはマシ」でいいからさ。なので比較的単純なほうの AES を使ってみることにする。

サンプルによればこうね:

実行例は Python 2.7 でのもの
 1 >>> from Crypto.Cipher import AES
 2 >>> from Crypto import Random
 3 >>>
 4 >>> key = b'Sixteen byte key'
 5 >>> iv = Random.new().read(AES.block_size)
 6 >>> cipher = AES.new(key, AES.MODE_CFB, iv)
 7 >>> e = cipher.encrypt(b'Attack at dawn')
 8 >>> msg = iv + e
 9 >>> cipher.decrypt(iv + e)[len(iv):]
10 'Attack at dawn'

initialization vector (コードでの iv) はモードによって必要だったり必要じゃなかったりする。というよりは「モードによっては与えることが出来る」。MODE_ECB と MODE_CTR では IV は与えても無視される。

initialization vector のあるなしでは当然これがある方が「予測可能性」を低くすることが出来る。これがないってことは、「秘密」は key だけになっちゃうわけよ。「アルゴリズム」は秘密とはいえない秘密なので。key はパスワードをベースに決めたいとしても、initialization vector なしではより暗号化後データからの推測がされやすいってこと(*)。そういうわけで、initialization vector ありのモードを使いたい、とする。(実際予告の通りのニーズでは「やりすぎ」と言えないこともないんだけれども。)

さて、ここまで決めたところで、今時点での「秘密」とその扱い方の方向性を一瞥してみる:

  1. 「暗号化の際に生成した initialization vector」という秘密。
    • 生成時点での乱数である、今のケースでは。
    • 乱数でなくてもアプリケーション固有の秘密文字列でもストアを工夫する限り選択肢には入る。
    • initialization vector は公式説明で「公開可能」とされている。
      • つまり「弱い秘密」である。
      • 完全に公開したとしても、やらないよりは暗号化後データからの推測がされにくい。
      • 暗号化後データと分割して格納すればより強い、ことは強い。
      • けれども分割配置は置き場所の安全性、という検討課題が増えるのも確か。
  2. 「暗号化で使った key」という秘密 (箱の鍵)
    • これは「アプリケーションのみぞ知る」秘密ではなく「ユーザのみぞ知る」秘密にしたい。
    • つまり都度入力させたい。(getpass で。)
    • 都度入力するのなら格納場所に困るという問題はクリアである。
    • ただし、AES の key はサイズ固定なので、パスワードそのものを key にするのはキツい。
    • さらに AES の key サイズは 16 (AES-128), 24 (AES-192), 32 (AES-256) と強度に直接対応。
    • ので AES の key は入力パスワードをハッシュ (SHA256 のダイジェストが丁度 32 バイト)
    • (釈迦に説法なひとには釈迦に説法だが hexdigest はひとさま向けの冗長表現。「SHA256=64バイト」になるのはコンパクトでない16進「文字列」表現だから。)
  3. 「暗号化後データ」 (鍵で開ける箱)
    • 置き場所問題。原理原則的にはほかの秘密とセットで配置するのは厳禁。
    • とはいえそれもニーズ依存。
    • どこに配置したのか失念して「紛失」するのは危ないのかも、というケースもあるだろう。

鍵をかけるという行為よりも、その鍵をどうやって保管・管理するのかが問題だ、ってことな。わかるでしょ、日常とマッピングしちゃえば。けどなぜか「技術的」に説明されると迷子になって一番大事なこれを忘れてしまうエンジニアはとっても多いのです、ってハナシ。

さて。じゃぁ今の場合どうするか。注意せねばならんのが(?)、「いやいや、今そこまで望んでおらんのよ」てことである。要するに「置き場所には困りたくない」んである。無論「秘密の分散管理」が安全度を高めるのは事実だとしてもだ、今のニーズでは明らかに牛さんに刀クンである。

秘密の格納場所に困るくらいなら公開の秘密はスクリプトに埋め込んじまえよ

あれこれ「置き場所に困る問題」に悩むほどのニーズではないのな、今の場合。

だから initialization vector はスクリプトに埋め込むくらいでもいい。いや…、もっと簡易に、「暗号化後データ」 (鍵で開ける箱)さえもスクリプトに埋め込んじまえよ、と。

ここまで割り切ると、真の秘密は「毎度入力するパスワード」だけとなり、initialization vector の効能も「ないよりは強い」ことだけになる。けど今はこれでいいわけである。これでも「スクリプトに平文でパスワードを埋め込む」よりも何千倍も良いのであるから。

ところで「スクリプトに埋め込んじまえ」ってどゆこと? こゆこと:

  • 必要情報からスクリプトを生成する「ジェネレータスクリプト」
  • と、それにより生成された「特定ホストへのログイン専用 putty ラッパー」
    • (ここに getpass で入力する秘密以外の秘密は全部埋め込み)

You, 作っちゃいなよ

「ジェネレータスクリプト」はこういうことだわ:

  1. ホスト、ユーザ、ポート、パスワードを入力させる – ※1
  2. これを暗号化するためのパスワードを入力させる – ※2
  3. 乱数を initialization vector として作る – ※3
  4. ※1 を json とかの扱いやすい形にした上で ※2、※3 を使って暗号化 – ※4
  5. 生成後スクリプトには ※3 と ※4 を剥き身で埋め込み、getpass でのパスワード入力と復号化を仕込む
  6. 生成後スクリプトは subprocess を使って putty を起動

てわけで作ったのがこんなジェネレータスクリプト:

putty_cl_wrapper_gen.py (ワタシは MSYS 環境の /usr/bin に置いた)
 1 #! /usr/bin/env python
 2 # -*- coding: utf-8 -*-
 3 # * Require: pycrypto <https://pypi.python.org/pypi/pycrypto/>
 4 # * This script DOES NOT WORK with Python 3.x.
 5 import json
 6 from getpass import getpass
 7 from Crypto.Cipher import AES
 8 from Crypto import Random
 9 from Crypto.Hash.SHA256 import SHA256Hash
10 
11 
12 acc_info = dict(
13     host=raw_input("host for ssh account? "),
14     user=raw_input("user for ssh account? "),
15     port=int(raw_input("port for ssh account? ")),
16     pswd=getpass("password for ssh account? "))
17 sec = json.dumps(acc_info)
18 key = SHA256Hash(getpass("password for generated wrapper? ")).digest()
19 iv = Random.new().read(AES.block_size)
20 cipher = AES.new(key, AES.MODE_CFB, iv)
21 enc_sec = cipher.encrypt(sec)
22 
23 
24 # ============================================================
25 # 
26 # common parts of scripts
27 #
28 script_pre = """\
29 #! /usr/bin/env python
30 # -*- coding: utf-8 -*-
31 # * Require: pycrypto <https://pypi.python.org/pypi/pycrypto/>
32 # * This script DOES NOT WORK with Python 3.x.
33 import os
34 import sys
35 import json
36 from subprocess import Popen
37 from getpass import getpass
38 from Crypto.Cipher import AES
39 from Crypto.Hash.SHA256 import SHA256Hash
40 
41 
42 PUTTY_PATH = "c:/Program Files (x86)/PuTTY"
43 iv = {iv}
44 enc_sec = {enc_sec}
45 key = SHA256Hash(getpass()).digest()
46 cipher = AES.new(key, AES.MODE_CFB, iv)
47 
48 acc_info = json.loads(cipher.decrypt(iv + enc_sec)[len(iv):])
49 """.format(iv=repr(iv), enc_sec=repr(enc_sec))
50 
51 # ============================================================
52 # 
53 # for putty.exe
54 #
55 putty_script = script_pre + """\
56 cmdline = [
57     os.path.join(PUTTY_PATH, "putty.exe"),
58     "-P", str(acc_info["port"]),
59     "-pw", acc_info["pswd"],
60     "{}@{}".format(acc_info["user"], acc_info["host"]),
61     ]
62 print(" ".join(cmdline))
63 pid = Popen(cmdline).pid  # spawn NO WAIT
64 """
65 with open("putty_{host}_{user}_{port}.py".format(**acc_info), "wb") as fo:
66     fo.write(putty_script)
67 
68 # ============================================================
69 # 
70 # for pscp.exe
71 #
72 pscp_script = script_pre + """\
73 cmdline_targets = [
74     s.replace("{{remote}}", "{}@{}".format(acc_info["user"], acc_info["host"]))
75     for s in sys.argv[1:]
76     ]
77 cmdline = [
78     os.path.join(PUTTY_PATH, "pscp.exe"),
79     "-P", str(acc_info["port"]),
80     "-pw", acc_info["pswd"]
81     ] + cmdline_targets
82 print(" ".join(cmdline))
83 pid = Popen(cmdline).pid  # spawn NO WAIT
84 """
85 with open("pscp_{host}_{user}_{port}.py".format(**acc_info), "wb") as fo:
86     fo.write(pscp_script)

なんにせよ行儀のいいもんではない。自分さえ使えりゃいい、ってノリなので。Python 2.7 でしか動かない、と言ってるけど実際は数箇所触るだけで動きます (raw_input 部分と文字列の扱い)。

これを、

  • サーバ名(ホスト名)は ssh999.lolipop.jp
  • ユーザ名(アカウント名)は yourdomain-yourname
  • ポート番号は 9822
  • パスワードは ZjJjZTFlOTg5YjI3MmNmY2I2Nzc2MTY3

だとして「生成」すると、(例えば)こんなスクリプト(putty用とpscp用の2つ)が出来上がる:

putty_ssh999.lolipop.jp_yourdomain-yourname_9822.py
 1 #! /usr/bin/env python
 2 # -*- coding: utf-8 -*-
 3 # * Require: pycrypto <https://pypi.python.org/pypi/pycrypto/>
 4 # * This script DOES NOT WORK with Python 3.x.
 5 import os
 6 import json
 7 from subprocess import Popen
 8 from getpass import getpass
 9 from Crypto.Cipher import AES
10 from Crypto.Hash.SHA256 import SHA256Hash
11 
12 
13 PUTTY_PATH = "c:/Program Files (x86)/PuTTY"
14 iv = '\xeb\xeb5\xc5} l\x80\xec\x10\xbe\xd6;\xad\xd2\x1c'
15 enc_sec = "\xda\xad\xd5\xe6h\xcd\xba(\xc8\x1dUT\xc7h\xe4\x9f\x8d\xb1\x14?&\x9f,#/BM\xf4\xce\xd9\xbe\xdbXc\x94\xb5\x1d(t\x02\xbd\x8fWv\xb0\x83\x05\xa1p(\x96IP9\x1f\xbb\x12\xcd\x9am\xa3I\xbd=\xc2\xc4\xaf\xf1\x013;\x98\xd1\\Ym;8\x03o\xd6\x01\xa9z\xce\x15\xd36\xf0*'6_2\xb9\x86jc\xe87~\x07\xa4\xcc\xb6:5\xaf\xc5%<\x19s+\xe4\xc0\xce\x84"
16 key = SHA256Hash(getpass()).digest()
17 cipher = AES.new(key, AES.MODE_CFB, iv)
18 
19 acc_info = json.loads(cipher.decrypt(iv + enc_sec)[len(iv):])
20 cmdline = [
21     os.path.join(PUTTY_PATH, "putty.exe"),
22     "-P", str(acc_info["port"]),
23     "-pw", acc_info["pswd"],
24     "{}@{}".format(acc_info["user"], acc_info["host"]),
25     ]
26 pid = Popen(cmdline).pid  # spawn NO WAIT
pscp_ssh999.lolipop.jp_yourdomain-yourname_9822.py
 1 #! /usr/bin/env python
 2 # -*- coding: utf-8 -*-
 3 # * Require: pycrypto <https://pypi.python.org/pypi/pycrypto/>
 4 # * This script DOES NOT WORK with Python 3.x.
 5 import os
 6 import sys
 7 import json
 8 from subprocess import Popen
 9 from getpass import getpass
10 from Crypto.Cipher import AES
11 from Crypto.Hash.SHA256 import SHA256Hash
12 
13 
14 PUTTY_PATH = "c:/Program Files (x86)/PuTTY"
15 iv = '\xeb\xeb5\xc5} l\x80\xec\x10\xbe\xd6;\xad\xd2\x1c'
16 enc_sec = "\xda\xad\xd5\xe6h\xcd\xba(\xc8\x1dUT\xc7h\xe4\x9f\x8d\xb1\x14?&\x9f,#/BM\xf4\xce\xd9\xbe\xdbXc\x94\xb5\x1d(t\x02\xbd\x8fWv\xb0\x83\x05\xa1p(\x96IP9\x1f\xbb\x12\xcd\x9am\xa3I\xbd=\xc2\xc4\xaf\xf1\x013;\x98\xd1\\Ym;8\x03o\xd6\x01\xa9z\xce\x15\xd36\xf0*'6_2\xb9\x86jc\xe87~\x07\xa4\xcc\xb6:5\xaf\xc5%<\x19s+\xe4\xc0\xce\x84"
17 key = SHA256Hash(getpass()).digest()
18 cipher = AES.new(key, AES.MODE_CFB, iv)
19 
20 acc_info = json.loads(cipher.decrypt(iv + enc_sec)[len(iv):])
21 cmdline_targets = [
22     s.replace("{remote}", "{}@{}".format(acc_info["user"], acc_info["host"]))
23     for s in sys.argv[1:]
24     ]
25 cmdline = [
26     os.path.join(PUTTY_PATH, "pscp.exe"),
27     "-P", str(acc_info["port"]),
28     "-pw", acc_info["pswd"]
29     ] + cmdline_targets
30 pid = Popen(cmdline).pid  # spawn NO WAIT

みての通りで「秘密をそのままスクリプトに埋め込んで」いることは疑いようもない事実。ま、initialization vector が乱数なのをいいことに、ときどき再生成するとかすれば「安心」ではあるかもしらんね。でもその「怖ろしい見かけ」よりは全然安全だ、ということはお忘れなく。これまで説明してきた通りです。「本当に本物のセキュリティ」からはたとえかけ離れていたとしても、です。

なお pscp の方なんだけど、これは「リモートからローカルへ」というコピーと「ローカルからリモートへ」をする必要があるでしょう? このときに結局「user@host」の知識が必要になるのね。なのでこのための「埋め込み文字」を自作してる。ここでは「{remote}」という文字列にしてみた。

さて、この生成後スクリプト、もう一つチャームポイントがある。それはスクリプトファイル名ね。「pscp_ssh999.lolipop.jp_yourdomain-yourname_9822.py」てなものなのでまずは「識別しやすい」のと、あと bash などの「コマンドライン編集が優秀なシェル」では簡単に呼び出せるのね、これ。GUI で設定に名前を付けるのと似たノリね。これがあってこそ「きゃーステキ」と思える。こうしないなら多分都度ダルい。

ほかの代替案と最後の念押し

ほかの、といっても「秘密の分散格納」についての話だけ少し。

たぶん「紛失の危険がない程度には一元管理に近しい」という場所そのものの検討と、もう一つが秘密を分散格納する際に、普通「もう一つ以上の秘密が必要になる」ことを考えないといけないのが厄介なはず。後者は「秘密1と秘密2の関連付け」のこと。これが毎度考えるの面倒なのよねぇ。

格納場所としては Windows の場合は案外レジストリなんかが悪くない選択肢なのよね。第一にスクリプトの配置場所に依存しない記憶域であるという点と、ファイルシステム上のファイルと違って「標準の場所」の悩みが少ない。ファイルシステム上に設定ファイルなんかで置くことを考えるとさ、「環境変数 HOME がセットされてるとは限らない Windows」ではとかく困るし、ほかの「Windows 標準」はユーザからみると「そう標準でもないし、ひとによっては迷惑にさえ感じる (「マイドキュメント」にドキュメントを置かないユーザが大半だろ)」。ただ「HOME 問題」の解決が何某かつくんであれば、pickle や sqlite も悪くない。これは後者なんか特にそもそもがテキストファイルじゃないのが「気分的な安心感」には繋がるかもしれない。(要するにジイさまやらお子ちゃまでも簡単に読めちゃうかそうでないかの違い程度は出る。)







さて。では最後に。

最初に予告した通りで、「結論のスクリプト」は「お奨めだぜい」なんてことはなくて、理解せずに真似しちゃダメよ、ってレベルのもんです。この記事の「ワタクシ的目的」はもちろん「メモ」が主食なんだけれど、「ひとさま向け」には実は「思考過程」を曝すことにある。

実際本職 SE は (そればっかりを専門にしてない限りは) こういった「本格的にセキュリティ問題の設計に取り組む」という担当になることは結構頻度的には少なくて(実装は多いとしてもだぞ)、「何をポイントに考えねばならんのか」の勘所がわからないまま飛び込むハメになることが結構多い。これまで身の回りをみてきて、「セキュリティ問題をそれなりに知っていると思い込んでいるバカ」も何人も見たし、そういうバカでなくても「突如その必要に迫られる哀れなエンジニア」もまぁまぁ見てきたわけで、そんな中でさ、前者のバカエンジニアは「最も脆弱なフローの分析」は「なんちゃってセキュリティ」よわばりになり、「戻るボタン禁止」が「必須のセキュリティ」となる、という「トンデモセキュリティ」がまかり通るし、後者の哀れなエンジニアが(気付かずに)「http://some.server.com/hogecgi.cgi?password=Himitsu」をやらかす、てのをね、本当に実物をみてきたわけよ。

「難しいものは確かに先天的に難しい」のです。であるから考えねばならぬことは都度「真剣に」考えなければならんのですがね、ダメ人間は「自分が理解出来ないことはあってはならないことなので理解出来るように曲解する」(無論当人は「さぁ曲解するぜ」と思ってそうするわけではない)。セキュリティの問題が難しいのは本当は、「難しい」ということそのものよりも、この「是が非でも簡単であると思い込もうとする思考癖」との闘いなんだと思う。







つーか「なんぢゃそりゃ」って結びでスマンの。ネタが「インチキネタ」だからこそ、な。