git-cipher 0.1

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 (3) hide show
  1. checksums.yaml +7 -0
  2. data/bin/git-cipher +380 -0
  3. metadata +50 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: f65b15e499a669209962429243007bbdbbdaaeaa
4
+ data.tar.gz: 21f8abf07fbcb9ef6c7c55c75f78530b965c8815
5
+ SHA512:
6
+ metadata.gz: 614dfcf2bb60ce0f183d20d3c23aa032f58b9b8e5b69bf6fa679c6bd3fd5a94625790099d327526fd7fe353f063fb95646bac1b71cae7ff9bc276005b49f244b
7
+ data.tar.gz: dc7c936e44505b66408eb91c9c19fa736cee2805c5a44e131547ba93382b6ffce0aaf11abe451e983d18b2642f1ce9ae502e9ba148b719627aa37c75d943417d
data/bin/git-cipher ADDED
@@ -0,0 +1,380 @@
1
+ #!/usr/bin/env ruby
2
+ # git-cipher -- encrypt/decrypt files
3
+
4
+ require 'fileutils'
5
+ require 'pathname'
6
+ require 'shellwords'
7
+
8
+ class Cipher
9
+ EXTENSION = 'encrypted'
10
+ DEFAULT_GPG_USER = 'greg@hurrell.net'
11
+ DEFAULT_GPG_PRESET_COMMAND = '/usr/local/opt/gpg-agent/libexec/gpg-preset-passphrase'
12
+ VALID_OPTIONS = %w[force help]
13
+ VALID_SUBCOMMANDS = %w[decrypt encrypt help preset forget]
14
+
15
+ def run
16
+ send @subcommand
17
+ end
18
+
19
+ private
20
+
21
+ def initialize
22
+ @subcommand, @options, @files = process_args
23
+
24
+ if @options.include?('help') || @subcommand == 'help'
25
+ usage(@subcommand)
26
+ end
27
+
28
+ @force = @options.include?('force')
29
+ end
30
+
31
+ def blue(string)
32
+ colorize(string, 34)
33
+ end
34
+
35
+ def check_ignored(path)
36
+ `#{escape command_path('git')} check-ignore -q #{escape path}`
37
+ puts "[warning: #{path} is not ignored]" unless $?.exitstatus.zero?
38
+ end
39
+
40
+ def colorize(string, color)
41
+ "\e[#{color}m#{string}\e[0m"
42
+ end
43
+
44
+ def command_name
45
+ @command_name ||= begin
46
+ if `ps -p #{Process.ppid.to_i}` =~ /\bgit cipher\b/
47
+ 'git cipher'
48
+ else
49
+ File.basename(__FILE__)
50
+ end
51
+ rescue
52
+ File.basename(__FILE__)
53
+ end
54
+ end
55
+
56
+ def command_path(command)
57
+ path = `command -v #{escape command}`.chomp
58
+ die "required dependency #{command} not found" if path.empty?
59
+ path
60
+ end
61
+
62
+ def decrypt
63
+ if @files.empty?
64
+ puts 'No explicit paths supplied: decrypting all matching files'
65
+ Dir["**/.*.#{EXTENSION}"].each { |file| decrypt!(file) }
66
+ else
67
+ @files.each { |file| decrypt!(file) }
68
+ end
69
+ end
70
+
71
+ def decrypt!(file)
72
+ unless ENV['GPG_AGENT_INFO']
73
+ die <<-MSG
74
+ GPG_AGENT_INFO not present in the environment.
75
+ Try running:
76
+ eval $(gpg-agent --daemon)
77
+ #{command_name} preset
78
+ MSG
79
+ end
80
+
81
+ pathname = Pathname.new(file)
82
+ basename = pathname.basename.to_s
83
+ unless basename.start_with?('.')
84
+ die "#{file} does not begin with a period"
85
+ end
86
+
87
+ unless basename.end_with?(".#{EXTENSION}")
88
+ die "#{file} does not have an .#{EXTENSION} extension"
89
+ end
90
+
91
+ unless pathname.exist?
92
+ die "#{file} does not exist"
93
+ end
94
+
95
+ outfile = pathname.dirname + basename.gsub(
96
+ /\A\.|\.#{EXTENSION}\z/, ''
97
+ )
98
+
99
+ print "#{file} -> #{outfile} "
100
+
101
+ if FileUtils.uptodate?(outfile, [file]) && !@force
102
+ # decrypted is newer than encrypted; it might have local changes which we
103
+ # could blow away, so warn
104
+ puts red('[warning: plain-text newer than ciphertext; skipping]')
105
+ else
106
+ print green('[decrypting ...')
107
+ execute(%{
108
+ #{escape command_path('gpg')}
109
+ -q
110
+ --yes
111
+ --batch
112
+ --no-tty
113
+ --use-agent
114
+ -o #{escape outfile}
115
+ -d #{escape file}
116
+ })
117
+ if $?.success?
118
+ puts green(' done]')
119
+
120
+ File.chmod(0600, outfile)
121
+
122
+ # mark plain-text as older than ciphertext, this will prevent a
123
+ # bin/encrypt run from needlessly changing the contents of the ciphertext
124
+ # (as the encryption is non-deterministic)
125
+ time = File.mtime(file) - 1
126
+ File.utime(time, time, outfile)
127
+ else
128
+ print kill_line
129
+ puts red('[decrypting ... failed; bailing]')
130
+ exit $?.exitstatus
131
+ end
132
+
133
+ check_ignored(outfile)
134
+ end
135
+ end
136
+
137
+ def die(msg)
138
+ STDERR.puts red('error:'), strip_heredoc(msg)
139
+ exit 1
140
+ end
141
+
142
+ def encrypt
143
+ if @files.empty?
144
+ puts 'No explicit paths supplied: encrypting all matching files'
145
+ Dir["**/.*.#{EXTENSION}"].each do |file|
146
+ file = Pathname.new(file)
147
+ encrypt!(
148
+ file.dirname +
149
+ file.basename.to_s.gsub(/\A\.|\.#{EXTENSION}\z/, '')
150
+ )
151
+ end
152
+ else
153
+ @files.each { |file| encrypt!(Pathname.new(file)) }
154
+ end
155
+ end
156
+
157
+ def encrypt!(file)
158
+ unless file.exist?
159
+ die "#{file} does not exist"
160
+ end
161
+
162
+ outfile = file.dirname + ".#{file.basename}.#{EXTENSION}"
163
+
164
+ print "#{file} -> #{outfile} "
165
+ if FileUtils.uptodate?(outfile, [file]) && !@force
166
+ puts blue('[up to date]')
167
+ else
168
+ print green('[encrypting ...')
169
+ execute(%{
170
+ #{escape command_path('gpg')}
171
+ -a
172
+ -q
173
+ --batch
174
+ --no-tty
175
+ --yes
176
+ -r #{escape gpg_user}
177
+ -o #{escape outfile}
178
+ -e #{escape file}
179
+ })
180
+ if $?.success?
181
+ puts green(' done]')
182
+ else
183
+ print kill_line
184
+ puts red('[encrypting ... failed; bailing]')
185
+ exit $?.exitstatus
186
+ end
187
+ end
188
+
189
+ File.chmod(0600, file)
190
+ check_ignored(file)
191
+ end
192
+
193
+ def escape(string)
194
+ Shellwords.escape(string)
195
+ end
196
+
197
+ def execute(string)
198
+ %x{#{string.gsub("\n", ' ')}}
199
+ end
200
+
201
+ def forget
202
+ `#{escape command_path(gpg_preset_command)} --forget #{keygrip}`
203
+ die 'gpg-preset-passphrase failed' unless $?.success?
204
+ end
205
+
206
+ def get_config(key)
207
+ value = `#{escape command_path('git')} config cipher.#{key}`.chomp
208
+ return if value.empty?
209
+ value
210
+ end
211
+
212
+ def get_passphrase
213
+ stty = escape(command_path('stty'))
214
+ print 'Passphrase [will not be echoed]: '
215
+ `#{stty} -echo` # quick hack, cheaper than depending on highline gem
216
+ STDIN.gets.chomp
217
+ ensure
218
+ `#{stty} echo`
219
+ puts
220
+ end
221
+
222
+ def gpg_preset_command
223
+ ENV['GPG_PRESET_COMMAND'] || get_config('presetcommand') || DEFAULT_GPG_PRESET_COMMAND
224
+ end
225
+
226
+ def gpg_user
227
+ ENV['GPG_USER'] || get_config('gpguser') || DEFAULT_GPG_USER
228
+ end
229
+
230
+ def green(string)
231
+ colorize(string, 32)
232
+ end
233
+
234
+ def keygrip
235
+ # get the subkey fingerprint and convert it into a 40-char hex code
236
+ @keygrip ||= execute(%{
237
+ #{escape command_path('gpg')} --fingerprint #{escape gpg_user} |
238
+ grep fingerprint |
239
+ tail -1 |
240
+ cut -d= -f2 |
241
+ sed -e 's/ //g'
242
+ })
243
+ end
244
+
245
+ def kill_line
246
+ # 2K deletes the line, 0G moves to column 0
247
+ # see: http://en.wikipedia.org/wiki/ANSI_escape_code
248
+ "\e[2K\e[0G"
249
+ end
250
+
251
+ def normalize_option(option)
252
+ normal = option.dup
253
+
254
+ if normal.sub!(/\A--/, '') # long option
255
+ found = VALID_OPTIONS.find { |o| o == normal }
256
+ elsif normal.sub!(/\A-/, '') # short option
257
+ found = VALID_OPTIONS.find { |o| o[0] == normal }
258
+ end
259
+
260
+ die "unrecognized option: #{option}" if found.nil?
261
+
262
+ found
263
+ end
264
+
265
+ def preset
266
+ passphrase = get_passphrase
267
+ command = "#{escape command_path(gpg_preset_command)} --preset #{keygrip}"
268
+ IO.popen(command, 'w+') do |io|
269
+ io.puts passphrase
270
+ io.close_write
271
+ puts io.read # usually silent
272
+ end
273
+ die 'gpg-preset-passphrase failed' unless $?.success?
274
+ end
275
+
276
+ def process_args
277
+ options, files = ARGV.partition { |arg| arg.start_with?('-') }
278
+ subcommand = files.shift
279
+
280
+ options.map! { |option| normalize_option(option) }
281
+
282
+ unless VALID_SUBCOMMANDS.include?(subcommand)
283
+ if subcommand.nil?
284
+ message = 'no subcommand'
285
+ else
286
+ message = 'unrecognized subcommand'
287
+ end
288
+ die [message, "expected one of #{VALID_SUBCOMMANDS.inspect}"].join(': ')
289
+ end
290
+
291
+ [subcommand, options, files]
292
+ end
293
+
294
+ def red(string)
295
+ colorize(string, 31)
296
+ end
297
+
298
+ def strip_heredoc(doc)
299
+ # based on method of same name from Rails
300
+ indent = doc.scan(/^[ \t]*(?=\S)/).map(&:size).min || 0
301
+ doc.gsub(/^[ \t]{#{indent}}/, '')
302
+ end
303
+
304
+ # Print usage information and exit.
305
+ def usage(subcommand)
306
+ case subcommand
307
+ when 'decrypt'
308
+ puts strip_heredoc(<<-USAGE)
309
+ #{command_name} decrypt [-f|--force] [FILES...]
310
+
311
+ Decrypts files that have been encrypted for storage in version control
312
+
313
+ Decrypt two files, but only if the corresponding plain-text files
314
+ are missing or older:
315
+
316
+ #{command_name} decrypt .foo.encrypted .bar.encrypted
317
+
318
+ Decrypt all decryptable files:
319
+
320
+ #{command_name} decrypt
321
+
322
+ (Re-)decrypt all decryptable files, even those whose corresponding
323
+ plain-text files are newer:
324
+
325
+ #{command_name} decrypt -f
326
+ #{command_name} decrypt --force # (alternative syntax)
327
+ USAGE
328
+ when 'encrypt'
329
+ puts strip_heredoc(<<-USAGE)
330
+ #{command_name} encrypt [-f|--force] [FILES...]
331
+
332
+ Encrypts files for storage in version control
333
+
334
+ Encrypt two files, but only if the corresponding ciphertext files
335
+ are missing or older:
336
+
337
+ #{command_name} encrypt foo bar
338
+
339
+ Encrypt all encryptable files:
340
+
341
+ #{command_name} encrypt
342
+
343
+ (Re-)encrypt all encryptable files, even those whose corresponding
344
+ ciphertext files are newer:
345
+
346
+ #{command_name} encrypt -f
347
+ #{command_name} encrypt --force # (alternative syntax)
348
+ USAGE
349
+ when 'forget'
350
+ puts strip_heredoc(<<-USAGE)
351
+ #{command_name} forget
352
+
353
+ Forget passphrase previously stored using `#{command_name} preset`:
354
+
355
+ #{command_name} forget
356
+ USAGE
357
+ when 'preset'
358
+ puts strip_heredoc(<<-USAGE)
359
+ #{command_name} preset
360
+
361
+ Store a passphrase in a running `gpg-agent` agent:
362
+
363
+ #{command_name} preset
364
+ USAGE
365
+ else
366
+ puts strip_heredoc(<<-USAGE)
367
+ Available commands (invoke any with -h or --help for more info):
368
+
369
+ #{command_name} decrypt
370
+ #{command_name} encrypt
371
+ #{command_name} forget
372
+ #{command_name} preset
373
+ USAGE
374
+ end
375
+
376
+ exit
377
+ end
378
+ end
379
+
380
+ Cipher.new.run
metadata ADDED
@@ -0,0 +1,50 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: git-cipher
3
+ version: !ruby/object:Gem::Version
4
+ version: '0.1'
5
+ platform: ruby
6
+ authors:
7
+ - Greg Hurrell
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-03-31 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: "\n Provides a convenient workflow for working with encrypted files
14
+ in a public\n Git repo. Delegates the underlying work of encryption/decryption
15
+ to GnuPG.\n "
16
+ email:
17
+ - greg@hurrell.net
18
+ executables:
19
+ - git-cipher
20
+ extensions: []
21
+ extra_rdoc_files: []
22
+ files:
23
+ - bin/git-cipher
24
+ homepage: https://github.com/wincent/git-cipher
25
+ licenses:
26
+ - MIT
27
+ metadata: {}
28
+ post_install_message:
29
+ rdoc_options: []
30
+ require_paths:
31
+ - lib
32
+ required_ruby_version: !ruby/object:Gem::Requirement
33
+ requirements:
34
+ - - '>='
35
+ - !ruby/object:Gem::Version
36
+ version: '0'
37
+ required_rubygems_version: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - '>='
40
+ - !ruby/object:Gem::Version
41
+ version: '0'
42
+ requirements:
43
+ - Git
44
+ - GnuPG
45
+ rubyforge_project:
46
+ rubygems_version: 2.0.14
47
+ signing_key:
48
+ specification_version: 4
49
+ summary: Manages encrypted content in a Git repo
50
+ test_files: []