s7n 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. data/.gitignore +7 -0
  2. data/Gemfile +4 -0
  3. data/Gemfile.lock +28 -0
  4. data/LICENCE +24 -0
  5. data/README.rdoc +54 -0
  6. data/Rakefile +67 -0
  7. data/bin/s7ncli +23 -0
  8. data/lib/s7n.rb +12 -0
  9. data/lib/s7n/action.rb +7 -0
  10. data/lib/s7n/attribute.rb +226 -0
  11. data/lib/s7n/cipher.rb +110 -0
  12. data/lib/s7n/configuration.rb +41 -0
  13. data/lib/s7n/entry.rb +106 -0
  14. data/lib/s7n/entry_collection.rb +44 -0
  15. data/lib/s7n/entry_template.rb +10 -0
  16. data/lib/s7n/exception.rb +116 -0
  17. data/lib/s7n/file.rb +77 -0
  18. data/lib/s7n/gpass_file.rb +203 -0
  19. data/lib/s7n/key.rb +83 -0
  20. data/lib/s7n/message_catalog.rb +5 -0
  21. data/lib/s7n/s7n_file.rb +47 -0
  22. data/lib/s7n/s7ncli.rb +226 -0
  23. data/lib/s7n/s7ncli/attribute_command.rb +83 -0
  24. data/lib/s7n/s7ncli/command.rb +215 -0
  25. data/lib/s7n/s7ncli/entry_collection_command.rb +63 -0
  26. data/lib/s7n/s7ncli/entry_command.rb +728 -0
  27. data/lib/s7n/s7ncli/option.rb +101 -0
  28. data/lib/s7n/secret_generator.rb +30 -0
  29. data/lib/s7n/undo_stack.rb +7 -0
  30. data/lib/s7n/unicode_data.rb +29 -0
  31. data/lib/s7n/utils.rb +37 -0
  32. data/lib/s7n/version.rb +3 -0
  33. data/lib/s7n/world.rb +158 -0
  34. data/po/ja/s7n.po +533 -0
  35. data/po/s7n.pot +533 -0
  36. data/s7n.gemspec +30 -0
  37. data/test/s7n/attribute_test.rb +50 -0
  38. data/test/s7n/gpass_file_test.rb +169 -0
  39. data/test/s7n/gpass_file_test/passwords.gps.empty +1 -0
  40. data/test/s7n/gpass_file_test/passwords.gps.one_entry +2 -0
  41. data/test/s7n/gpass_file_test/passwords.gps.three_entries +3 -0
  42. data/test/s7n/gpass_file_test/passwords.gps.with_folders +0 -0
  43. data/test/s7n/secret_generator_test.rb +29 -0
  44. data/test/s7n/unicode_data_test.rb +28 -0
  45. data/test/s7n/world_test.rb +35 -0
  46. data/test/test_helper.rb +11 -0
  47. metadata +153 -0
@@ -0,0 +1,203 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require "s7n/key"
4
+ require "s7n/cipher"
5
+ require "s7n/entry_collection"
6
+ require "s7n/attribute"
7
+ require "s7n/exception"
8
+ require "gettext"
9
+
10
+ module S7n
11
+ # GPass のファイルに関する処理を表現する。
12
+ class GPassFile
13
+ include GetText
14
+
15
+ # GPass のファイルが格納されているデフォルトのパス。
16
+ DEFAULT_PATH = "~/.gpass/passwords.gps"
17
+
18
+ # Blowfish の初期化配列。
19
+ IV = [5, 23, 1, 123, 12, 3, 54, 94].pack("C8")
20
+
21
+ # GPass のファイルであることを示すマジック。
22
+ MAGIC_PATTERN = /\AGPassFile version (\d\.\d\.\d)/
23
+
24
+ # GPass で扱う秘密情報の種類と属性。
25
+ ENTRY_ATTRIBUTE_TEMPLATES = {
26
+ "entry" => [TextAttribute.new("name" => N_("name")),
27
+ TextAttribute.new("name" => N_("description")),
28
+ DateTimeAttribute.new("name" => N_("created_at")),
29
+ DateTimeAttribute.new("name" => N_("updated_at")),
30
+ BooleanAttribute.new("name" => N_("expiration")),
31
+ DateTimeAttribute.new("name" => N_("expire_at"))],
32
+ "folder" => ["entry"],
33
+ "password" => ["entry",
34
+ TextAttribute.new("name" => N_("username")),
35
+ TextAttribute.new("name" => N_("password"),
36
+ "secret" => true)],
37
+ "general" => ["password",
38
+ TextAttribute.new("name" => N_("hostname"))],
39
+ "shell" => ["general"],
40
+ "website" => ["password",
41
+ TextAttribute.new("name" => N_("url"))],
42
+ }
43
+
44
+ ENTRY_ATTRIBUTE_TEMPLATES.each do |name, template|
45
+ loop do
46
+ replaced = false
47
+ template.each_with_index do |attr, i|
48
+ if attr.is_a?(String)
49
+ template[i] = ENTRY_ATTRIBUTE_TEMPLATES[attr]
50
+ replaced = true
51
+ end
52
+ end
53
+ if replaced
54
+ template.flatten!
55
+ else
56
+ break
57
+ end
58
+ end
59
+ end
60
+
61
+ def read(passphrase, path = DEFAULT_PATH)
62
+ res = EntryCollection.new
63
+ File.open(File.expand_path(path), "r") do |f|
64
+ f.flock(File::LOCK_EX)
65
+ s = decrypt(f, passphrase)
66
+ parse(s, res)
67
+ end
68
+ return res
69
+ end
70
+
71
+ private
72
+
73
+ def decrypt(io, passphrase)
74
+ key = Key.create_instance("SHA-1", passphrase)
75
+ cipher = Cipher.create_instance("BF-CBC", :key => key, :iv => IV)
76
+ return cipher.decrypt(io)
77
+ end
78
+
79
+ def parse(s, entry_collection)
80
+ if s.slice!(MAGIC_PATTERN).nil?
81
+ raise InvalidPassphrase
82
+ end
83
+ version = $1
84
+ # TODO: 1.1.0 より前のバージョンもサポートする。
85
+ if version != "1.1.0"
86
+ raise GPassFileError, _("not supported version: %s") % version
87
+ end
88
+ id_entry = {}
89
+ while !s.empty?
90
+ res = parse_gpass_entry(s)
91
+ id = res["id"]
92
+ entry = res["entry"]
93
+ if id_entry.key?(id)
94
+ raise GPassFileError, _("duplicated entry id: %s") % id
95
+ end
96
+ id_entry[id] = entry
97
+ parent = id_entry[res["parent_id"]]
98
+ if parent
99
+ entry.tags.push(*parent.tags)
100
+ end
101
+ if res["type"] == "folder"
102
+ entry.tags.push(entry.name)
103
+ else
104
+ entry_collection.add_entries(entry)
105
+ end
106
+ end
107
+ end
108
+
109
+ def parse_gpass_entry(s)
110
+ id = unpack_number(s)
111
+ parent_id = unpack_number(s)
112
+ type = unpack_string(s, unpack_number(s))
113
+ bytes = unpack_number(s)
114
+ if s.length < bytes
115
+ raise DataLengthError.new(s.length, bytes)
116
+ end
117
+ entry = Entry.new
118
+ unpack_attributes(entry, type, s.slice!(0, bytes))
119
+ return {
120
+ "id" => id,
121
+ "parent_id" => parent_id,
122
+ "type" => type,
123
+ "entry" => entry,
124
+ }
125
+ end
126
+
127
+ def unpack_attributes(entry, type, s)
128
+ template = ENTRY_ATTRIBUTE_TEMPLATES[type]
129
+ if !template
130
+ raise GPassFileError, _("invalid entry type: type=<%s>") % type
131
+ end
132
+ template.each do |a|
133
+ attr = a.dup
134
+ case attr
135
+ when NumericAttribute
136
+ attr.value = unpack_variable_number(s)
137
+ when BooleanAttribute
138
+ attr.value = unpack_boolean(s)
139
+ when DateTimeAttribute
140
+ attr.value = unpack_time(s)
141
+ when TextAttribute
142
+ attr.value = unpack_string(s, unpack_variable_number(s))
143
+ end
144
+ entry.add_attributes(attr)
145
+ end
146
+ end
147
+
148
+ # s で指定したバイト列から 4 バイト取り出し、リトルエンディアンの数
149
+ # 値として unpack した結果を返す。
150
+ def unpack_number(s)
151
+ if s.length < 4
152
+ raise DataLengthError.new(s.length, 4)
153
+ end
154
+ return s.slice!(0, 4).unpack("V").first
155
+ end
156
+
157
+ # s で指定したバイト列から 8 ビット目が 0 のバイトを見つけるまで 1
158
+ # バイトずつ取り出し、4 バイトのリトルエンディアンの数値として
159
+ # unpack した結果を返す。
160
+ def unpack_variable_number(s)
161
+ n = 0
162
+ base = 1
163
+ i = 0
164
+ while (t = s.slice!(0)) && i <= 5
165
+ t = t.ord
166
+ if t & 0x80 == 0
167
+ n += base * t
168
+ break
169
+ else
170
+ n += base * (t & 0x7f)
171
+ base *= 0x80
172
+ end
173
+ i += 1
174
+ end
175
+ return [n].pack("V").unpack("I").first
176
+ end
177
+
178
+ # s で指定したバイト列から n で指定した長さを取り出し、それを文字列
179
+ # として返す。
180
+ def unpack_string(s, n)
181
+ if s.length < n
182
+ raise DataLengthError.new(s.length, n)
183
+ end
184
+ return s.slice!(0, n).force_encoding("utf-8")
185
+ end
186
+
187
+ # s で指定したバイト列から unpack_variable_number を使用して数値を
188
+ # 取り出す。それを time_t の値として扱い、DateTimeオブジェクトを返す。
189
+ def unpack_time(s)
190
+ return Time.at(unpack_variable_number(s)).to_datetime
191
+ end
192
+
193
+ # s で指定したバイト列から unpack_variable_number を使用して数値を
194
+ # 取り出す。それが 0 であれば false を、そうでなければ true を返す。
195
+ def unpack_boolean(s)
196
+ return unpack_variable_number(s) != 0
197
+ end
198
+
199
+ # GPass のファイルの処理中に発生したエラーを表現する例外クラス。
200
+ class GPassFileError < ApplicationError
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,83 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require "openssl"
4
+
5
+ module S7n
6
+ # 暗号化用のキーを表現する。
7
+ class Key
8
+ @@key_classes = {}
9
+
10
+ class << self
11
+ def create_instance(type, passphrase)
12
+ return @@key_classes[type].new(passphrase)
13
+ end
14
+
15
+ private
16
+
17
+ def key_type(name)
18
+ if @@key_classes.nil?
19
+ @@key_classes = {}
20
+ end
21
+ @@key_classes[name] = self
22
+ self.const_set("KEY_TYPE", name)
23
+ end
24
+ end
25
+
26
+ attr_reader :key
27
+ attr_reader :length
28
+
29
+ def initialize(passphrase)
30
+ @key = OpenSSL::Digest.digest(hash_type, passphrase)
31
+ @length = @key.length
32
+ end
33
+
34
+ private
35
+
36
+ def hash_type
37
+ begin
38
+ return self.class.const_get("HASH_TYPE")
39
+ rescue NameError
40
+ return self.class.const_get("KEY_TYPE")
41
+ end
42
+ end
43
+ end
44
+
45
+ class Md5Key < Key
46
+ key_type "MD5"
47
+ end
48
+
49
+ class Sha1Key < Key
50
+ key_type "SHA-1"
51
+ HASH_TYPE = "SHA1"
52
+ end
53
+
54
+ begin
55
+ OpenSSL::Digest.const_get("SHA224")
56
+
57
+ class Sha224Key < Key
58
+ key_type "SHA-224"
59
+ HASH_TYPE = "SHA224"
60
+ end
61
+ rescue NameError
62
+ end
63
+
64
+ begin
65
+ OpenSSL::Digest.const_get("SHA256")
66
+
67
+ class Sha256Key < Key
68
+ key_type "SHA-256"
69
+ HASH_TYPE = "SHA256"
70
+ end
71
+ rescue NameError
72
+ end
73
+
74
+ begin
75
+ OpenSSL::Digest.const_get("SHA512")
76
+
77
+ class Sha512Key < Key
78
+ key_type "SHA-512"
79
+ HASH_TYPE = "SHA512"
80
+ end
81
+ rescue NameError
82
+ end
83
+ end
@@ -0,0 +1,5 @@
1
+ module S7n
2
+ # 属性の名前を国際化するときに使用するメッセージカタログを表現する。
3
+ class MessageCatalog
4
+ end
5
+ end
@@ -0,0 +1,47 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require "fileutils"
4
+ require "stringio"
5
+ require "s7n/utils"
6
+ require "s7n/cipher"
7
+
8
+ module S7n
9
+ # s7 オリジナルのファイル形式で機密情報の読み書きを表現する。
10
+ module S7nFile
11
+ # マジック。
12
+ MAGIC = "s7 file version 0\n"
13
+
14
+ # data で指定したバイト列を暗号化して書き込む。
15
+ def write(path, master_key, cipher_type, data)
16
+ cipher = Cipher.create_instance(cipher_type, :passphrase => master_key)
17
+ FileUtils.touch(path)
18
+ File.open(path, "r+b") do |f|
19
+ f.flock(File::LOCK_EX)
20
+ data = [MAGIC,
21
+ cipher_type + "\n",
22
+ cipher.encrypt(StringIO.new(data))].join
23
+ f.write(data)
24
+ f.truncate(f.pos)
25
+ end
26
+ FileUtils.chmod(0600, path)
27
+ end
28
+ module_function :write
29
+
30
+ # path で指定したファイルを読み込み、master_key で指定したマスター
31
+ # キーを使用して復号する。
32
+ def read(path, master_key)
33
+ File.open(path, "rb") do |f|
34
+ f.flock(File::LOCK_EX)
35
+ magic = f.gets
36
+ if MAGIC != magic
37
+ raise InvalidPassphrase
38
+ end
39
+ cipher_type = f.gets.chomp
40
+ cipher = Cipher.create_instance(cipher_type,
41
+ :passphrase => master_key)
42
+ return cipher.decrypt(f)
43
+ end
44
+ end
45
+ module_function :read
46
+ end
47
+ end
@@ -0,0 +1,226 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require "optparse"
4
+ require "readline"
5
+ require "gettext"
6
+ require "fileutils"
7
+ require "s7n/s7ncli/option"
8
+ require "s7n/s7ncli/command"
9
+ command_pattern =
10
+ File.expand_path("s7ncli/*_command.rb", File.dirname(__FILE__))
11
+ Dir.glob(command_pattern).each do |path|
12
+ require path.gsub(/\.rb\z/, "")
13
+ end
14
+
15
+ # s7n のコマンドラインインタフェースを表現する。
16
+ module S7n
17
+ class S7nCli
18
+ include GetText
19
+
20
+ # 最初に実行するメソッド。
21
+ def self.run(argv)
22
+ obj = S7nCli.new
23
+ obj.parse_command_line_argument(argv)
24
+ obj.run
25
+ end
26
+
27
+ # prompt に指定した文字列を表示後、Readline ライブラリを利用してユー
28
+ # ザからの入力を得る。
29
+ def self.input_string(prompt = "", add_history = true)
30
+ input = Readline.readline(prompt, add_history)
31
+ input.force_encoding("utf-8") if input
32
+ return input
33
+ end
34
+
35
+ # prompt に指定した文字列を表示後、エコーバックせずにユーザからの入
36
+ # 力を得る。パスフレーズの入力を想定。
37
+ def self.input_string_without_echo(prompt = "")
38
+ print(prompt) if prompt.length > 0
39
+ STDOUT.flush
40
+ Utils.execute("stty -echo")
41
+ begin
42
+ input = STDIN.gets
43
+ ensure
44
+ Utils.execute("stty echo")
45
+ puts("")
46
+ end
47
+ if input
48
+ input.chomp!
49
+ input.force_encoding("utf-8")
50
+ end
51
+ return input
52
+ end
53
+
54
+ # prompt に指定した文字列を表示後、ユーザからの入力を得る。
55
+ # それがyes、y、true、t、okのいずれかであれば true を返し、
56
+ # そうでなければ false を返す。大小文字は区別しない。
57
+ # ^D などにより何も入力しない場合、defualt で指定した真偽値を返す。
58
+ def self.input_boolean(prompt = "", default = false)
59
+ if input = Readline.readline(prompt, false)
60
+ input.strip!
61
+ if input.empty?
62
+ return default
63
+ end
64
+ input.force_encoding("utf-8")
65
+ if /\Ay|yes|t|true|ok\z/i =~ input
66
+ return true
67
+ else
68
+ return false
69
+ end
70
+ else
71
+ return default
72
+ end
73
+ end
74
+
75
+ # World クラスのインスタンス。
76
+ attr_accessor :world
77
+
78
+ def initialize
79
+ @world = World.new
80
+ end
81
+
82
+ # コマンドラインオプションを解析する。
83
+ def parse_command_line_argument(argv)
84
+ option_parser = OptionParser.new { |opts|
85
+ opts.banner = _("usage: s7ncli [options]")
86
+ opts.separator("")
87
+ opts.separator(_("options:"))
88
+ opts.on("-d", "--base-dir=DIR",
89
+ _("Specify the base directory. Default is '%s'.") % world.base_dir) do |dir|
90
+ world.base_dir = dir
91
+ end
92
+ opts.on("--debug",
93
+ _("Turn on debug mode. Default is '%s'.") % world.debug) do
94
+ world.debug = true
95
+ end
96
+ opts.on("-v", "--version", _("Show version.")) do
97
+ puts(_("s7ncli, version %s") % S7n::VERSION)
98
+ exit(0)
99
+ end
100
+ opts.on("-h", "--help",
101
+ _("Show help and exit.")) do
102
+ puts(opts)
103
+ exit(0)
104
+ end
105
+ }
106
+ option_parser.parse(argv)
107
+ end
108
+
109
+ # メインの処理を行う。
110
+ def run
111
+ if File.exist?(world.base_dir)
112
+ if !File.directory?(world.base_dir)
113
+ Utils::shred_string(master_key)
114
+ raise InvalidPath.new(world.base_dir)
115
+ end
116
+ world.lock
117
+ world.start_logging
118
+ begin
119
+ master_key =
120
+ S7nCli.input_string_without_echo(_("Enter the master key for s7n: "))
121
+ if master_key.nil?
122
+ exit(1)
123
+ end
124
+ world.load(S7nFile.read(world.secrets_path, master_key))
125
+ world.master_key = master_key
126
+ rescue InvalidPassphrase => e
127
+ puts(e.message)
128
+ retry
129
+ end
130
+ else
131
+ master_key =
132
+ S7nCli.input_string_without_echo(_("Enter the master key for s7n: "))
133
+ if master_key.nil?
134
+ exit(1)
135
+ end
136
+ confirmation =
137
+ S7nCli.input_string_without_echo(_("Comfirm the master key for s7n: "))
138
+ if master_key != confirmation
139
+ Utils::shred_string(master_key)
140
+ Utils::shred_string(confirmation) if !confirmation.nil?
141
+ raise InvalidPassphrase
142
+ end
143
+ world.master_key = master_key
144
+ FileUtils.mkdir_p(world.base_dir)
145
+ FileUtils.chmod(0700, world.base_dir)
146
+ world.start_logging
147
+ world.logger.info(_("created base directory: %s") % world.base_dir)
148
+ world.save(true)
149
+ world.lock
150
+ end
151
+ begin
152
+ path = File.join(world.base_dir, "s7ncli")
153
+ if File.file?(path)
154
+ begin
155
+ hash = YAML.load(S7nFile.read(path, world.master_key))
156
+ hash["history"].each_line do |line|
157
+ Readline::HISTORY.push(line.chomp)
158
+ end
159
+ rescue
160
+ puts(_("could not read: %s") % path)
161
+ raise
162
+ end
163
+ end
164
+ loop do
165
+ begin
166
+ prompt = "%ss7n> " % (world.changed ? "*" : "")
167
+ input = S7nCli.input_string(prompt, true)
168
+ if !input
169
+ puts("")
170
+ break
171
+ end
172
+ name, argv = *Command.split_name_and_argv(input)
173
+ if !name
174
+ next
175
+ end
176
+ command = Command.create_instance(name, world)
177
+ if command
178
+ if !command.run(argv)
179
+ break
180
+ end
181
+ else
182
+ raise ApplicationError, _("Unknown command: %s") % name
183
+ end
184
+ rescue Interrupt
185
+ puts("")
186
+ retry
187
+ rescue => e
188
+ puts(e.message)
189
+ if world.debug
190
+ puts(_("----- back trace -----"))
191
+ e.backtrace.each do |line|
192
+ puts(" #{line}")
193
+ end
194
+ end
195
+ end
196
+ end
197
+ ensure
198
+ begin
199
+ world.save
200
+ begin
201
+ path = File.join(world.base_dir, "s7ncli")
202
+ # TODO: history の上限を設ける。
203
+ hash = {
204
+ "history" => Readline::HISTORY.to_a.join("\n"),
205
+ }
206
+ S7nFile.write(path, world.master_key,
207
+ world.configuration.cipher_type, hash.to_yaml)
208
+ rescue
209
+ puts(_("could not write: %s") % path)
210
+ puts($!)
211
+ end
212
+ ensure
213
+ world.unlock
214
+ end
215
+ end
216
+ rescue ApplicationError => e
217
+ puts(e.message)
218
+ if world.debug
219
+ puts(GetText._("----- back trace -----"))
220
+ e.backtrace.each do |line|
221
+ puts(" #{line}")
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end