はじめての Python
-パスワード管理スクリプトをつくってあそぼう-

Hello world!, Python

かねてから、ッターン!とキーボードを叩いてパスワードを取得、追加、編集、管理できないものかと思案していた。「1 PasswordLastPass を使ったらいいじゃん」という内なる怠け者の声も聞こえたが、なんだか楽しそうなので自分で作ってみることにした。

これまで困っていた点はおおよそ以下。

  1. 一つのテキストファイルにまとめるよう努めていたが、全然関係ない場所に見つかることもたまによくある。
  2. テキストファイルにたどりつくまでが面倒。場所を忘れる。
  3. テキストファイルの中から目当てのパスワードを探すのが面倒。

これらの問題を解決してくれる痒いところに手の届くツールが出来上がった(だって自分で作ったから!)ので、ここにまとめておきたい。最後にサンプルコードを、途中途中に試行錯誤の悶々を記す。

ちなみにはじめての Python で作った理由は、パスワードを JSON 形式で保存しようと決めたからだ。「 JSON と Python ってちょっと似てるよね」と天啓を得たわけである。

なお、実行環境は macOS Sierra(10.12.4)python 2.7.13 である。

シンプルなパスワード管理コマンドラインツール

主に以下の機能を持つツールに仕上がった。

  1. JSON 形式で保存
  2. 追加する
  3. 探す
  4. 更新する
  5. 表示する
  6. 操作する前にマスターパスワードを確認する
  7. 自動でバックアップをとる
  8. 値は暗号化して保存する

保存方法と、実際に実行するための4操作をご紹介。

JSON 形式で保存

パスワードは JSON 形式でテキストファイルに保存することにした。当初から

  • データベースは大袈裟
  • CSVは扱いにくい
  • Apache の .htpasswd も参考にしたがやはり扱いにくい

と感じており、 JSON に落ち着いた。JSON ちょうどいい。

{
    "amazon": {
        "id": "myID", 
        "pw": "myPassword"
    }, 
    "google": {
        "id": "myID", 
        "pw": "myPassword"
    }, 
    "labelname": {
        "key1": "value1", 
        "key2": "value2"
    }
}

追加する

パスワードを新しく追加したい場合は以下のコマンド。

$ addpass
enter your new password
Usage:: labelname key1:value1 key2:value2 key3:value3
 # ← Usage にならって入力

コマンドを実行すると入力を求められるので、Usage にならって入力する。入力された値はスペースで区切って解釈される。

最初のスペースまでは、グループ化するための名前。それ以後はセミコロンで分割して key と value として保存。key:value のペアはいくつあっても良い。value は暗号化して保存する。

なお実行の度に JSON ファイルは/path/to/password/.tmp/ ディレクトリへコピーをとっている。

探す

パスワードを得たい場合は以下のコマンド。

$ getpass labelname key

パスワードが見つかれば、画面に出力する。クリップボードにコピーもされているのでそのまま他所へ貼り付ければ良い。便利。イメージ通りである。

更新する

addpass コマンドは labelname が重複してる場合、操作をやめる。更新する場合はこちら。

$ updatepass
labelname: # ← 入力
key: # ← 入力
value: # ← 入力

表示する

登録済みパスワードを一覧した場合は以下。

$listpass

テキストファイルなので、 $ less /path/to/password/password.json で十分かもしれない。

サンプルコード

最終的なコードも掲載する。

まずはシェルから扱えるように .bashrc に関数を定義した。自作した password.py に引数を与えて実行するだけ。

# get password
getpass(){
	return=`python /path/to/password/password.py get $1 $2`
	echo $return|pbcopy
	echo $return
}
# add password
addpass(){
	python /path/to/password/password.py add
}
# update password
updatepass(){
	python /path/to/password/password.py update
}
# list password
listpass(){
	python /path/to/password/password.py list
}

続けて本体となる password.py

# coding: UTF-8

import json, sys, os, shutil, time, base64
from getpass import getpass
from Crypto import Random
from Crypto.Cipher import AES
from Crypto.Hash import SHA256

# クラス定義
class MyPassword:
	# 
	# コンストラクタ
	# 
	def __init__(self):
		self.dir          = os.path.dirname(os.path.abspath(__file__))
		self.jsonfilepath = self.dir + '/password.json'
		self.tmpdir       = self.dir + '/.tmp'
		self.secret_key   = b'16バイトの任意の文字列を指定する'
		self.check_masterpass()
		self.mknewfile()

	# 
	# json から値を取得
	# 
	def get(self):
		try:
			with open(self.jsonfilepath, 'r') as json_data:
				json_obj     = json.load(json_data)
				target_label = sys.argv[2]
				target_key   = sys.argv[3]
				return_value = self.decrypt(json_obj[target_label][target_key])
				print(return_value)
		except IOError:
			print('IOError: ファイルを開くことができませんでした。')
		except KeyError:
			print('KeyError: 指定した値がパスワードリストにない可能性があります。')
		except IndexError: 
			print('IndexError: 引数の数に問題がある可能性があります。')
		except Exception as e:
			raise e

	# 
	# 新しいパスワードを json に追加
	# 
	def add(self):
		# ファイルをリネームしてコピー
		self.backup()

		# ユーザからの入力を受け取って整理
		try:
			s = raw_input('enter your password\nUsage:: labelname key1:value1 key2:value2 key3:value3\n')
			s = s.split(' ')
			labelname      = s[0]
			add_dict_items = s[1:]
			add_dict       = {labelname:{}} # 初期化

			# 引数はあるだけ処理
			for item in add_dict_items:
				key   = item.split(':', 1)[0] # セミコロンより前
				value = self.encrypt(item.split(':', 1)[1]) # セミコロンより後ろ
				add_dict[labelname].update({key:value})
		except IndexError:
			print('IndexError: ')
		except Exception as e:
			raise e

		# 既存パスワードに新しいパスワードを追加
		try:
			with open(self.jsonfilepath, 'r') as json_data:
				json_obj = json.load(json_data)
				if labelname in json_obj:
					raise NameError('labelname: ' + labelname + ' is already exists.')
				else:
					json_obj.update(add_dict)
		except IOError:
			print('IOError: ファイルの読み込み')
		except Exception as e:
			raise e

		# 書き込み
		try:
			with open(self.jsonfilepath, 'w') as json_data:
				json.dump(json_obj, json_data, indent = 4, sort_keys=True)
		except IOError:
			print('IOError: ファイルの書き込み')
		except Exception as e:
			raise e

	# 
	# label, key を指定して value を更新
	# 
	def update(self):
		# ファイルをリネームしてコピー
		self.backup()

		labelname = raw_input('labelname: ')
		key       = raw_input('key: ')
		value     = raw_input('value: ')

		# 既存パスワードに新しいパスワードを追加
		try:
			with open(self.jsonfilepath, 'r') as json_data:
				json_obj = json.load(json_data)
				json_obj[labelname].update({key: self.encrypt(value)})
		except IOError:
			print('IOError: ファイルの読み込み')
		except KeyError:
			print('KeyError: 指定した値がパスワードリストにない可能性があります。')
		except Exception as e:
			raise e

		# 書き込み
		try:
			with open(self.jsonfilepath, 'w') as json_data:
				json.dump(json_obj, json_data, indent = 4, sort_keys=True)
		except IOError:
			print('IOError: ファイルの書き込み')
		except Exception as e:
			raise e

	# 
	# 登録済みパスワードの一覧を表示
	# 
	def list(self):
		try:
			with open(self.jsonfilepath, 'r') as json_data:
				json_obj = json.load(json_data)
				for labelname in json_obj:
					print(labelname)
					for key in json_obj[labelname]:
						print('\t' + key)
		except Exception as e:
			raise e

	# 
	# 暗号化
	# 
	def encrypt(self, raw):
		IV     = Random.new().read(AES.block_size)
		cipher = AES.new(self.secret_key, AES.MODE_CFB, IV)
		return base64.b64encode(IV + cipher.encrypt(raw))

	# 
	# 復号化
	# 
	def decrypt(self, encode):
		encode = base64.b64decode(encode)
		IV     = encode[:AES.block_size]
		cipher = AES.new(self.secret_key, AES.MODE_CFB, IV)
		return cipher.decrypt(encode[AES.block_size:])

	# 
	# マスターパスワードの確認
	# 
	def check_masterpass(self):
		userinput = getpass('Password: ')
		hash      = SHA256.new()
		hash.update(userinput)
		if hash.hexdigest() == '任意の文字列から、予めハッシュをつくっておく':
			pass
		else:
			sys.exit('Error: invalid password')

	# 
	# json のバックアップファイルをつくる
	# 
	def backup(self):
		self.mkdir()
		shutil.copy(self.jsonfilepath, self.tmpdir + '/tmp.' + str(time.time()))

	# 
	# バックアップファイルの格納ディレクトリをつくる
	# 
	def mkdir(self):
		if os.path.isdir(self.tmpdir) is False:
			os.mkdir(self.tmpdir)

	# 
	# json ファイルがない場合はつくる
	# 
	def mknewfile(self):
		if os.path.isfile(self.jsonfilepath) is False:
			with open(self.jsonfilepath, 'w') as newfile:
				empty = {}
				json.dump(empty, newfile)


# 実行
mypassword = MyPassword()

if 'get' == sys.argv[1]:
	mypassword.get()
elif 'add' == sys.argv[1]:
	mypassword.add()
elif 'update' == sys.argv[1]:
	mypassword.update()
elif 'list' == sys.argv[1]:
	mypassword.list()
else:
	print('Error.')
	print('Four actions are available. get/add/update/list')
	print('Usage:: $ python ' + sys.argv[0] + ' [get|add|update|list] ...')

おわりに

今回、極めて個人向けで極めて小さなツールを作成した。

作り始めるとどんどん楽しくなってしまい、気がつけば徹夜をしてしまった。こんなことは久しぶりだ。シェルの openssl コマンドを使って暗号化/復号化を試みるという大変な紆余曲折バージョンも作ってしまった。結局 trap の使い方がまるでわからずボツになり一から作り直した。それも楽しかった。

思えば、自分のためにプログラムを書いたことは初めてだったかもしれない。学生時代は研究のため、会社では仕事のためのプログラムだった。困ったことはネットを探して解決していた。なぜ今回がはじめての自作だったかはわからない。「なんだか楽しそう」だったから始めたがそのなんだかは正しかった。

忘れていたが再確認したのことは、私は、プログラミングもさることながら、色々と思案を巡らせ調べ試すことが好きで楽しいのだろう。あんなこんな機能がいるな、これは使い勝手が悪いからダメだな、セキュリティとかパスワードの扱いってどうしたらいいの、例外処理ってこれでいいのかな。

「車輪の再発明」などというけれど、なかなかどうして、存外悪いものではない。その点についてはいつかどこかの記事で読んだ気がする。記事の指摘の通り、私は今回で一皮向けたように思う。スキルの向上ではなく意識の変化が大きい。プログラミングは楽しいな、もっとできるようになりたいな、と改めて思い直したよい機会になった。

なんだかいい感じになってきたところでそろそろパソコンを閉じよう。徹夜でコードを書いた勢いそのままにこの記事も書いている。朝なのである。ねむい。

では。