dotenv-gpg 0.3

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.
@@ -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