dotenv-gpg 0.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,220 @@
1
+ class Dotgpg
2
+ class Dir
3
+
4
+ attr_reader :path
5
+
6
+ # Find the Dotgpg::Dir that contains the given path.
7
+ #
8
+ # If multiple are given only returns the directory if it contains all
9
+ # paths.
10
+ #
11
+ # If no path is given, find the Dotgpg::Dir that contains the current
12
+ # working directory.
13
+ #
14
+ # @param [*Array<String>] paths
15
+ # @return {nil|[Dotgpg::Dir]}
16
+ def self.closest(path=".", *others)
17
+ path = Pathname.new(File.absolute_path(path)).cleanpath
18
+
19
+ result = path.ascend do |parent|
20
+ maybe = Dotgpg::Dir.new(parent)
21
+ break maybe if maybe.dotgpg?
22
+ end
23
+
24
+ if others.any? && closest(*others) != result
25
+ nil
26
+ else
27
+ result
28
+ end
29
+ end
30
+
31
+ # Open a Dotgpg::Dir
32
+ #
33
+ # @param [String] path The location of the directory
34
+ def initialize(path)
35
+ @path = Pathname.new(File.absolute_path(path)).cleanpath
36
+ end
37
+
38
+ # Get the keys currently associated with this directory.
39
+ #
40
+ # @return [Array<GPGME::Key>]
41
+ def known_keys
42
+ dotgpg.each_child.map do |key_file|
43
+ Dotgpg::Key.read key_file.open
44
+ end
45
+ end
46
+
47
+ # Decrypt the contents of path and write to output.
48
+ #
49
+ # The path should be absolute, and may point to outside
50
+ # this directory, though that is not recommended.
51
+ #
52
+ # @param [Pathname] path The file to decrypt
53
+ # @param [IO] output The IO to write to
54
+ # @return [Boolean] false if decryption failed for an understandable reason
55
+ def decrypt(path, output)
56
+ File.open(path) do |f|
57
+ signature = false
58
+ temp = GPGME::Crypto.new.decrypt f, passphrase_callback: Dotgpg.method(:passfunc) do |s|
59
+ signature = s
60
+ end
61
+
62
+ unless ENV["DOTGPG_ALLOW_INJECTION_ATTACK"]
63
+ raise InvalidSignature, "file was not signed" unless signature
64
+ raise InvalidSignature, "signature was incorrect" unless signature.valid?
65
+ raise InvalidSignature, "signed by a stranger" unless known_keys.include?(signature.key)
66
+ end
67
+
68
+ output.write temp.read
69
+ end
70
+ true
71
+ rescue GPGME::Error::NoData, GPGME::Error::DecryptFailed, SystemCallError => e
72
+ Dotgpg.warn path, e
73
+ false
74
+ end
75
+
76
+ # Encrypt the input and write it to the given path.
77
+ #
78
+ # The path should be absolute, and may point to outside
79
+ # this directory, though that is not recommended.
80
+ #
81
+ # @param [Pathname] path The desired destination
82
+ # @param [IO] input The IO containing the plaintext
83
+ # @return [Boolean] false if encryption failed for an understandable reason
84
+ def encrypt(path, input)
85
+ File.open(path, "w") do |f|
86
+ GPGME::Crypto.new.encrypt input, output: f,
87
+ recipients: known_keys,
88
+ armor: true,
89
+ always_trust: true,
90
+ sign: true,
91
+ passphrase_callback: Dotgpg.method(:passfunc),
92
+ signers: known_keys.detect{ |key| GPGME::Key.find(:secret).include?(key) }
93
+ end
94
+ true
95
+ rescue SystemCallError => e
96
+ Dotgpg.warn path, e
97
+ false
98
+ end
99
+
100
+ # Re-encrypts a set of files with the currently known keys.
101
+ #
102
+ # If a block is provided, it can be used to edit the files in
103
+ # their temporary un-encrypted state.
104
+ #
105
+ # @param [Array<Pathname>] files the files to re-encrypt
106
+ # @yieldparam [Hash<Pathname, Tempfile>] the unencrypted files for each param
107
+ def reencrypt(files, &block)
108
+ tempfiles = {}
109
+
110
+ files.uniq.each do |f|
111
+ temp = Tempfile.new([File.basename(f), ".sh"])
112
+ tempfiles[f] = temp
113
+ if File.exist? f
114
+ decrypted = decrypt f, temp
115
+ tempfiles.delete f unless decrypted
116
+ end
117
+ temp.flush
118
+ temp.close(false)
119
+ end
120
+
121
+ yield tempfiles if block_given?
122
+
123
+ tempfiles.each_pair do |f, temp|
124
+ temp.open
125
+ temp.seek(0)
126
+ encrypt f, temp
127
+ end
128
+
129
+ nil
130
+ ensure
131
+ tempfiles.values.each do |temp|
132
+ temp.close(true)
133
+ end
134
+ end
135
+
136
+ # List every GPG-encrypted file in a directory recursively.
137
+ #
138
+ # Assumes the files are armored (non-armored files are hard to detect and
139
+ # dotgpg itself always armors)
140
+ #
141
+ # This is used to decide which files to re-encrypt when adding a user.
142
+ #
143
+ # @param [Pathname] dir
144
+ # @return [Array<Pathname>]
145
+ def all_encrypted_files(dir=path)
146
+ results = []
147
+ dir.each_child do |child|
148
+ if child.directory?
149
+ if !child.symlink? && child != dotgpg
150
+ results += all_encrypted_files(child)
151
+ end
152
+ elsif child.readable?
153
+ if child.read(1024) =~ /-----BEGIN PGP MESSAGE-----/
154
+ results << child
155
+ end
156
+ end
157
+ end
158
+
159
+ results
160
+ end
161
+
162
+ # Does this directory includea key for the given user yet?
163
+ #
164
+ # @param [GPGME::Key]
165
+ # @return [Boolean]
166
+ def has_key?(key)
167
+ File.exist? key_path(key)
168
+ end
169
+
170
+ # Add a given key to the directory
171
+ #
172
+ # Re-encrypts all files to add the new key as a recipient.
173
+ #
174
+ # @param [GPGME::Key]
175
+ def add_key(key)
176
+ reencrypt all_encrypted_files do
177
+ File.write key_path(key), key.export(armor: true).to_s
178
+ end
179
+ end
180
+
181
+ # Remove a given key from a directory
182
+ #
183
+ # Re-encrypts all files so that the removed key no-longer has access.
184
+ #
185
+ # @param [GPGME::Key]
186
+ def remove_key(key)
187
+ reencrypt all_encrypted_files do
188
+ key_path(key).unlink
189
+ end
190
+ end
191
+
192
+ # The path at which a key should be stored
193
+ #
194
+ # (i.e. .gpg/me@cirw.in)
195
+ #
196
+ # @param [GPGME::Key]
197
+ # @return [Pathname]
198
+ def key_path(key)
199
+ dotgpg + key.email
200
+ end
201
+
202
+ # The .gpg directory
203
+ #
204
+ # @return [Pathname]
205
+ def dotgpg
206
+ path + ".gpg"
207
+ end
208
+
209
+ # Does the .gpg directory exist?
210
+ #
211
+ # @return [Boolean]
212
+ def dotgpg?
213
+ dotgpg.directory?
214
+ end
215
+
216
+ def ==(other)
217
+ Dotgpg::Dir === other && other.path == self.path
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,105 @@
1
+ class Dotgpg
2
+ class Key
3
+
4
+ def self.read(file)
5
+ GPGME::Key.import(file).imports.map do |import|
6
+ GPGME::Key.find(:public, import.fingerprint)
7
+ end.flatten.first
8
+ end
9
+
10
+ def self.secret_key(email=nil, force_new=nil)
11
+ new.secret_key(email, force_new)
12
+ end
13
+
14
+ def secret_key(email=nil, force_new=nil)
15
+ existing = existing_key(email)
16
+ if existing && !force_new
17
+ existing
18
+ else
19
+ create_new_key email
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def existing_key(email=nil)
26
+ existing_private_keys.detect do |k|
27
+ email.nil? || k.email == email
28
+ end
29
+ end
30
+
31
+ def create_new_key(email=nil)
32
+ name = guess_name
33
+ email ||= guess_email
34
+
35
+ if email
36
+ puts "Creating a new GPG key: #{name} <#{email}>"
37
+ passphrase = get_passphrase
38
+ else
39
+ puts "Creating a new GPG key for #{name}"
40
+ email = get_email
41
+ passphrase = get_passphrase
42
+ end
43
+
44
+ puts "Generating large prime numbers, please wait..."
45
+ ctx = GPGME::Ctx.new
46
+ ctx.genkey(<<EOF, nil, nil)
47
+ <GnupgKeyParms format="internal">
48
+ Key-Type: RSA
49
+ Key-Length: 2048
50
+ Subkey-Type: RSA
51
+ Subkey-Length: 2048
52
+ Name-Real: #{name}
53
+ Name-Comment: dotgpg
54
+ Name-Email: #{email}
55
+ Expire-Date: 0
56
+ Passphrase: #{passphrase}
57
+ </GnupgKeyParms>
58
+ EOF
59
+
60
+ # return the most recently created key (race!)
61
+ GPGME::Key.find(:secret).sort_by{ |key|
62
+ key.primary_subkey.timestamp
63
+ }.last
64
+ end
65
+
66
+ def guess_name
67
+ name = `git config user.name 2>/dev/null`.strip
68
+ name = `whoami`.strip if name == ""
69
+ name
70
+ end
71
+
72
+ def guess_email
73
+ email = `git config user.email 2>/dev/null`.strip
74
+ email if email != ""
75
+ end
76
+
77
+ def get_email
78
+ email = ""
79
+ until email =~ /@/
80
+ email = Dotgpg.read_input "Email address: "
81
+ end
82
+ email
83
+ end
84
+
85
+ def get_passphrase
86
+ passphrase = confirmation = nil
87
+ until passphrase && passphrase == confirmation
88
+ times = 0
89
+ until passphrase && passphrase.length >= 10
90
+ times += 1
91
+ $stderr.puts "Passphrases should be secure! (>=10 chars)" if times >= 2
92
+ passphrase = Dotgpg.read_passphrase("Passphrase: ")
93
+ end
94
+ until confirmation && confirmation.length >= 10
95
+ confirmation = Dotgpg.read_passphrase("Passphrase confirmation: ")
96
+ end
97
+ end
98
+ passphrase
99
+ end
100
+
101
+ def existing_private_keys
102
+ GPGME::Key.find(:secret)
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,20 @@
1
+ This directory contains GPG-encrypted files managed by `dotgpg`.
2
+
3
+ Getting started
4
+ ---------------
5
+
6
+ To read files in this directory, send the output of running `dotgpg key` to someone
7
+ who has access already. They will be able to run `dotgpg add` on your behalf.
8
+
9
+ Usage
10
+ -----
11
+
12
+ You can edit any file with `dotgpg edit FILE`, and read any file with `dotgpg cat FILE`.
13
+
14
+ The edit command looks at the value of `$EDITOR`, the internet will have a tutorial on
15
+ how to set this up with your favourite editor.
16
+
17
+ Details
18
+ -------
19
+
20
+ For more information, please see `dotgpg --help`, or the [README](https://github.com/ConradIrwin/dotgpg).
@@ -0,0 +1,255 @@
1
+ require "./spec/helper"
2
+
3
+ describe Dotgpg::Cli do
4
+ before do
5
+ @dotgpg = Dotgpg::Cli.new
6
+ end
7
+
8
+ describe "init" do
9
+ it "should default to the current directory" do
10
+ $fixture.join("create-0").mkdir
11
+ Dir.chdir $fixture.join("create-0") do
12
+ @dotgpg.invoke(:init, [])
13
+ assert $fixture.join("create-0", ".gpg", "test@example.com").exist?
14
+ end
15
+ end
16
+
17
+ it "should create a .gpg directory" do
18
+ refute $fixture.join("create-1", ".gpg").exist?
19
+ @dotgpg.invoke(:init, [($fixture + "create-1").to_s])
20
+ assert $fixture.join("create-1", ".gpg").exist?
21
+ end
22
+
23
+ it "should add the user's secret key to the .gpg directory" do
24
+ @dotgpg.invoke(:init, [($fixture + "create-2").to_s])
25
+ assert_equal $fixture.join("create-2", ".gpg", "test@example.com").read, GPGME::Key.find(:secret).first.export(armor: true).to_s
26
+ end
27
+
28
+ it "should add a README to the directory" do
29
+ @dotgpg.invoke(:init, [($fixture + "create-3").to_s])
30
+ assert_equal $fixture.join("create-3", ".gpg", "test@example.com").read, GPGME::Key.find(:secret).first.export(armor: true).to_s
31
+ end
32
+
33
+ it "should fail if the .gpg directory already exists" do
34
+ FileUtils.mkdir_p $fixture + "create-4" + ".gpg"
35
+ assert_fails(/\.gpg already exists/) do
36
+ @dotgpg.invoke(:init, [($fixture + "create-4").to_s])
37
+ end
38
+ end
39
+
40
+ it "can succeed if the directory itself already exists" do
41
+ FileUtils.mkdir_p $fixture + "create-5"
42
+ @dotgpg.invoke(:init, [($fixture + "create-5").to_s])
43
+ assert $fixture.join("create-5", ".gpg").exist?
44
+ end
45
+ end
46
+
47
+ describe "key" do
48
+ it "should output the secret key" do
49
+ assert_outputs GPGME::Key.find(:secret).first.export(armor: true).to_s do
50
+ @dotgpg.invoke(:key)
51
+ end
52
+ end
53
+ end
54
+
55
+ describe "add" do
56
+ before do
57
+ @path = $fixture + rand.to_s.gsub(".", "")
58
+ @path.mkdir
59
+ Dir.chdir @path do
60
+ Dotgpg::Cli.new.invoke(:init, [])
61
+ end
62
+ end
63
+
64
+ it "should add the specified key" do
65
+ key_path = ($fixture + "add1.key").to_s
66
+ Dir.chdir @path do
67
+ @dotgpg.invoke(:add, [key_path])
68
+ end
69
+ assert Dotgpg::Dir.new(@path).has_key? Dotgpg::Key.read(File.read(key_path))
70
+ end
71
+
72
+ it "should abort if the current working directory is not dotgpg" do
73
+ key_path = ($fixture + "add1.key").to_s
74
+ assert_fails(/not in a dotgpg directory/) do
75
+ @dotgpg.invoke(:add, [key_path])
76
+ end
77
+ end
78
+
79
+ it "should abort if the key cannot be read" do
80
+ key_path = ($fixture + "no-add1.key").to_s
81
+ Dir.chdir @path do
82
+ assert_fails(/no-add1.key: not a valid GPG key/) do
83
+ @dotgpg.invoke(:add, [key_path])
84
+ end
85
+ end
86
+ end
87
+
88
+ it "should abort if the key already exists" do
89
+ key_path = ($fixture + "add1.key").to_s
90
+ Dir.chdir @path do
91
+ Dotgpg::Cli.new.invoke(:add, [key_path])
92
+
93
+ assert_fails(/add1@example.com: already exists/) do
94
+ @dotgpg.invoke(:add, [key_path])
95
+ end
96
+ end
97
+ end
98
+
99
+ it "should do nothing if the key exists and --force is specified" do
100
+ key_path = ($fixture + "add1.key").to_s
101
+ Dir.chdir @path do
102
+ Dotgpg::Cli.new.invoke(:add, [key_path])
103
+
104
+ @dotgpg.invoke(:add, [key_path], force: true)
105
+ end
106
+ end
107
+ end
108
+
109
+ describe "rm" do
110
+ before do
111
+ @path = $fixture + rand.to_s.gsub(".", "")
112
+ @path.mkdir
113
+ Dir.chdir @path do
114
+ Dotgpg::Cli.new.invoke(:init, [])
115
+ Dotgpg::Cli.new.invoke(:add, [($fixture + "add1.key").to_s])
116
+ end
117
+ end
118
+
119
+ it "should remove the specified key" do
120
+ Dir.chdir @path do
121
+ @dotgpg.invoke :rm, [".gpg/add1@example.com"]
122
+ end
123
+ refute Dotgpg::Dir.new(@path).has_key? Dotgpg::Key.read(File.read($fixture + "add1.key"))
124
+ end
125
+
126
+ it "should find the key by email" do
127
+ Dir.chdir @path do
128
+ @dotgpg.invoke :rm, ["add1@example.com"]
129
+ end
130
+ refute Dotgpg::Dir.new(@path).has_key? Dotgpg::Key.read(File.read($fixture + "add1.key"))
131
+ end
132
+
133
+ it "should abort if the key doesn't exist" do
134
+ Dir.chdir @path do
135
+ assert_fails(/add2@example.com: not a valid GPG key/) do
136
+ @dotgpg.invoke :rm, ["add2@example.com"]
137
+ end
138
+ end
139
+ end
140
+
141
+ it "should abort if the key is the user's secret key" do
142
+ Dir.chdir @path do
143
+ assert_fails(/test@example.com: refusing to remove your own secret key/) do
144
+ @dotgpg.invoke :rm, ["test@example.com"]
145
+ end
146
+ end
147
+ end
148
+
149
+ it "should do nothing if they key doesn't exist and --force is specified" do
150
+ Dir.chdir @path do
151
+ @dotgpg.invoke :rm, ["add2@example.com"], force: true
152
+ end
153
+ end
154
+
155
+ it "should remove a secret key if --force is given" do
156
+ key = Dotgpg::Key.read(File.read(@path + ".gpg" + "test@example.com"))
157
+ Dir.chdir @path do
158
+ @dotgpg.invoke :rm, ["test@example.com"], force: true
159
+ end
160
+
161
+ refute Dotgpg::Dir.new(@path).has_key? key
162
+ end
163
+ end
164
+
165
+ describe "cat" do
166
+ before do
167
+ Dotgpg.passphrase = 'test'
168
+
169
+ @path = $fixture + rand.to_s.gsub(".", "")
170
+ Dotgpg::Cli.new.invoke(:init, [@path.to_s])
171
+ Dotgpg::Dir.new(@path).encrypt @path + "a", "Test\n"
172
+ end
173
+
174
+ it "should cat an existing encrypted file" do
175
+ assert_outputs "Test\n" do
176
+ @dotgpg.invoke :cat, [(@path + "a").to_s]
177
+ end
178
+ end
179
+
180
+ it "should warn if a file doesn't exist" do
181
+ assert_warns "#{@path + "b"}: No such file or directory" do
182
+ @dotgpg.invoke :cat, [(@path + "b").to_s]
183
+ end
184
+ end
185
+
186
+ it "should cat the existing files if a mixture is specified" do
187
+ assert_outputs "Test\n" do
188
+ assert_warns "#{@path + "b"}: No such file or directory" do
189
+ @dotgpg.invoke :cat, [(@path + "b").to_s, (@path + "a").to_s]
190
+ end
191
+ end
192
+ end
193
+
194
+ it "should fail if the file is not in a .gpg directory" do
195
+ assert_fails "not in a dotgpg directory" do
196
+ @dotgpg.invoke :cat, ["/tmp/b"]
197
+ end
198
+ end
199
+
200
+ it "should fail if the passphrase is wrong" do
201
+ Dotgpg.passphrase = 'wrong'
202
+ assert_fails "Bad passphrase" do
203
+ @dotgpg.invoke :cat, [(@path + "a").to_s]
204
+ end
205
+ end
206
+ end
207
+
208
+ describe "edit" do
209
+ before do
210
+ Dotgpg.passphrase = 'test'
211
+
212
+ @path = $fixture + rand.to_s.gsub(".", "")
213
+ Dotgpg::Cli.new.invoke(:init, [@path.to_s])
214
+ Dotgpg::Dir.new(@path).encrypt @path + "a", "Bad test\n"
215
+
216
+ end
217
+
218
+ it "should let you edit an existing file" do
219
+ ENV['EDITOR'] = "sed -i '' s/Bad/Good/"
220
+ path = (@path + "a").to_s
221
+ @dotgpg.invoke(:edit, [path])
222
+ assert_outputs "Good test\n" do
223
+ Dotgpg::Dir.new(@path).decrypt path, $stdout
224
+ end
225
+ end
226
+
227
+ it "should open a non-existing file as blank" do
228
+ ENV['EDITOR'] = "ruby -e 'File.write(ARGV[0], %(Good test\n)) if File.read(ARGV[0]) == %()'"
229
+ path = (@path + "b").to_s
230
+ @dotgpg.invoke(:edit, [path])
231
+ assert_outputs "Good test\n" do
232
+ Dotgpg::Dir.new(@path).decrypt path, $stdout
233
+ end
234
+ end
235
+
236
+ it "should warn if a file cannot be decrypted" do
237
+ File.write(@path + "d", "not encrypted...")
238
+ path = (@path + "d").to_s
239
+ assert_warns "#{@path + "d"}: No data" do
240
+ @dotgpg.invoke(:edit, [path])
241
+ end
242
+ end
243
+
244
+ it "should fail if invoking the editor doesn't work" do
245
+ ENV['EDITOR'] = 'not-an-editor'
246
+ assert_fails "Problem with editor. Not saving changes" do
247
+ @dotgpg.invoke :edit, [(@path + "a").to_s]
248
+ end
249
+ end
250
+
251
+ it "should edit the existing files if a mixture is specified" do
252
+
253
+ end
254
+ end
255
+ end