git-cipher 0.2 → 1.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 (5) hide show
  1. checksums.yaml +5 -5
  2. data/bin/git-cipher +170 -120
  3. data/bin/git-cipher1 +524 -0
  4. data/bin/git-cipher2 +196 -0
  5. metadata +10 -9
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 3d52bb65c34660958a91e70fef43d596610bede9
4
- data.tar.gz: 7d83c6b13437a4fa8c1d166b76fda86abdf9a990
2
+ SHA256:
3
+ metadata.gz: 7d5b65afd443605f7782cec24d66093e353ac155e6b4a1e90aac40da2b8cbfbd
4
+ data.tar.gz: ab7448447f5ccd3b6467190315e9597e082ae01880ffcdb4cd0e3542b37b0b3e
5
5
  SHA512:
6
- metadata.gz: 53dec9da0d8e7bed2874793c25bde8e9fad254059bdccee796dec7b131a7191a6a863ced6873e797007167236de4a5f25dfc562b33e01a7506d98da32d7872cb
7
- data.tar.gz: e1c65440edaeb32687e7b124f6c497bd8e9413f4ef7c7f92a23d55d13ab29691a820eed6918ca819f6273e4308fb8c1a4d9cabd5939b4661da25ee785766e2f2
6
+ metadata.gz: 2dce18072bcee778726a04b35d2103552ba51c860b669428a7ce4c5f9c9c65e49866b6092f1d416b9b88b417a5d1c696be78d8f880b07e87ba71f2d75f0c6e8b
7
+ data.tar.gz: 91c20158875f8f66ebeb81924a8518f1df9265a1324dd8e9f8bf45ecb6d1787320194229c6e839525cbfc40b4fe622bc46a4e54403cbbb20d5264a8e2fb284fc
data/bin/git-cipher CHANGED
@@ -8,10 +8,15 @@ require 'tempfile'
8
8
 
9
9
  class Cipher
10
10
  EXTENSION = 'encrypted'
11
- DEFAULT_GPG_USER = 'greg@hurrell.net'
12
- DEFAULT_GPG_PRESET_COMMAND = '/usr/local/opt/gpg-agent/libexec/gpg-preset-passphrase'
11
+ DEFAULT_GPG_USERS = 'greg@hurrell.net,wincent@github.com'
12
+ EXECUTABLE_EXTENSIONS = %w[.js .sh]
13
+ STATUS = {
14
+ 'MISSING' => 0b001,
15
+ 'MODIFIED' => 0b010,
16
+ 'STALE' => 0b100,
17
+ }
13
18
  VALID_OPTIONS = %w[force help]
14
- VALID_SUBCOMMANDS = %w[decrypt encrypt help log preset forget]
19
+ VALID_SUBCOMMANDS = %w[decrypt encrypt help log ls status]
15
20
 
16
21
  def run
17
22
  send @subcommand
@@ -34,8 +39,7 @@ private
34
39
  end
35
40
 
36
41
  def check_ignored(path)
37
- `#{escape command_path('git')} check-ignore -q #{escape path}`
38
- puts "[warning: #{path} is not ignored]" unless $?.exitstatus.zero?
42
+ puts "[warning: #{path} is not ignored]" unless is_ignored?(path)
39
43
  end
40
44
 
41
45
  def colorize(string, color)
@@ -55,7 +59,7 @@ private
55
59
  end
56
60
 
57
61
  def command_path(command)
58
- path = `command -v #{escape command}`.chomp
62
+ path = `sh -c command\\ -v\\ #{escape command}`.chomp
59
63
  die "required dependency #{command} not found" if path.empty?
60
64
  path
61
65
  end
@@ -63,15 +67,13 @@ private
63
67
  def decrypt
64
68
  if @files.empty?
65
69
  puts 'No explicit paths supplied: decrypting all matching files'
66
- Dir["**/.*.#{EXTENSION}"].each { |file| decrypt!(file) }
70
+ matching.each { |file| decrypt!(file) }
67
71
  else
68
72
  @files.each { |file| decrypt!(file) }
69
73
  end
70
74
  end
71
75
 
72
76
  def decrypt!(file)
73
- require_agent
74
-
75
77
  pathname = Pathname.new(file)
76
78
  basename = pathname.basename.to_s
77
79
  unless basename.start_with?('.')
@@ -102,11 +104,11 @@ private
102
104
  if $?.success?
103
105
  puts green(' done]')
104
106
 
105
- File.chmod(0600, outfile)
107
+ File.chmod(mode(outfile), outfile)
106
108
 
107
- # mark plain-text as older than ciphertext, this will prevent a
109
+ # Mark plain-text as older than ciphertext, this will prevent a
108
110
  # bin/encrypt run from needlessly changing the contents of the ciphertext
109
- # (as the encryption is non-deterministic)
111
+ # (as the encryption is non-deterministic).
110
112
  time = File.mtime(file) - 1
111
113
  File.utime(time, time, outfile)
112
114
  else
@@ -127,7 +129,7 @@ private
127
129
  def encrypt
128
130
  if @files.empty?
129
131
  puts 'No explicit paths supplied: encrypting all matching files'
130
- Dir["**/.*.#{EXTENSION}"].each do |file|
132
+ matching.each do |file|
131
133
  file = Pathname.new(file)
132
134
  encrypt!(
133
135
  file.dirname +
@@ -150,17 +152,21 @@ private
150
152
  if FileUtils.uptodate?(outfile, [file]) && !@force
151
153
  puts blue('[up to date]')
152
154
  else
155
+ recipients = gpg_users.
156
+ split(/\s*,\s*/).
157
+ map { |u| "--recipient #{escape u}"}.
158
+ join(' ')
153
159
  print green('[encrypting ...')
154
160
  execute(%{
155
161
  #{escape command_path('gpg')}
156
- -a
157
- -q
162
+ --armor
163
+ --quiet
158
164
  --batch
159
165
  --no-tty
160
166
  --yes
161
- -r #{escape gpg_user}
162
- -o #{escape outfile}
163
- -e #{escape file}
167
+ #{recipients}
168
+ --output #{escape outfile}
169
+ --encrypt #{escape file}
164
170
  })
165
171
  if $?.success?
166
172
  puts green(' done]')
@@ -171,7 +177,7 @@ private
171
177
  end
172
178
  end
173
179
 
174
- File.chmod(0600, file)
180
+ File.chmod(mode(file), file)
175
181
  check_ignored(file)
176
182
  end
177
183
 
@@ -183,27 +189,12 @@ private
183
189
  %x{#{string.gsub("\n", ' ')}}
184
190
  end
185
191
 
186
- def forget
187
- `#{escape command_path(gpg_preset_command)} --forget #{keygrip}`
188
- die 'gpg-preset-passphrase failed' unless $?.success?
189
- end
190
-
191
192
  def get_config(key)
192
193
  value = `#{escape command_path('git')} config cipher.#{key}`.chomp
193
194
  return if value.empty?
194
195
  value
195
196
  end
196
197
 
197
- def get_passphrase
198
- stty = escape(command_path('stty'))
199
- print 'Passphrase [will not be echoed]: '
200
- `#{stty} -echo` # quick hack, cheaper than depending on highline gem
201
- STDIN.gets.chomp
202
- ensure
203
- `#{stty} echo`
204
- puts
205
- end
206
-
207
198
  def gpg_decrypt(file, outfile)
208
199
  execute(%{
209
200
  #{escape command_path('gpg')}
@@ -217,27 +208,17 @@ private
217
208
  })
218
209
  end
219
210
 
220
- def gpg_preset_command
221
- ENV['GPG_PRESET_COMMAND'] || get_config('presetcommand') || DEFAULT_GPG_PRESET_COMMAND
222
- end
223
-
224
- def gpg_user
225
- ENV['GPG_USER'] || get_config('gpguser') || DEFAULT_GPG_USER
211
+ def gpg_users
212
+ ENV['GPG_USER'] || get_config('gpguser') || DEFAULT_GPG_USERS
226
213
  end
227
214
 
228
215
  def green(string)
229
216
  colorize(string, 32)
230
217
  end
231
218
 
232
- def keygrip
233
- # get the subkey fingerprint and convert it into a 40-char hex code
234
- @keygrip ||= execute(%{
235
- #{escape command_path('gpg')} --fingerprint #{escape gpg_user} |
236
- grep fingerprint |
237
- tail -1 |
238
- cut -d= -f2 |
239
- sed -e 's/ //g'
240
- })
219
+ def is_ignored?(path)
220
+ `#{escape command_path('git')} check-ignore -q #{escape path}`
221
+ return $?.exitstatus.zero?
241
222
  end
242
223
 
243
224
  def kill_line
@@ -248,37 +229,36 @@ private
248
229
 
249
230
  def log
250
231
  if @files.empty?
251
- # TODO: would be nice to interleave these instead of doing them serially.
252
232
  puts 'No explicit paths supplied: logging all matching files'
253
- Dir["**/.*.#{EXTENSION}"].each { |file| log!(file) }
233
+ log!(nil)
254
234
  else
255
- @files.each { |file| log!(file) }
235
+ log!(@files)
256
236
  end
257
237
  end
258
238
 
259
- def log!(file)
260
- require_agent
261
-
262
- commits = execute(%{
263
- #{escape command_path('git')} log
264
- --pretty=format:%H -- #{escape file}
265
- }).split
239
+ def log!(files)
240
+ if files.nil?
241
+ commits = execute(%{
242
+ #{escape command_path('git')} log
243
+ --pretty=format:%H
244
+ --topo-order
245
+ -- **/.*.#{EXTENSION}
246
+ }).split
247
+ else
248
+ # Would use `--follow` here, but that only works with a single file and, more
249
+ # importantly, all encrypted files look like random noise, so `--follow`
250
+ # won't do anything useful; log will stop at first rename.
251
+ commits = execute(%{
252
+ #{escape command_path('git')} log
253
+ --pretty=format:%H
254
+ --topo-order
255
+ -- #{files.map { |f| escape(f) }.join(' ')}
256
+ }).split
257
+ end
266
258
 
267
259
  commits.each do |commit|
268
- files = []
260
+ tempfiles = []
269
261
  begin
270
- # Get plaintext "post" image.
271
- files.push(post = temp_write(show(file, commit)))
272
- files.push(
273
- post_plaintext = temp_write(gpg_decrypt(post.path, '-'))
274
- )
275
-
276
- # Get plaintext "pre" image.
277
- files.push(pre = temp_write(show(file, "#{commit}~")))
278
- files.push(pre_plaintext = temp_write(
279
- pre.size.zero? ? '' : gpg_decrypt(pre.path, '-')
280
- ))
281
-
282
262
  # Print commit message.
283
263
  puts execute(%{
284
264
  #{escape command_path('git')} --no-pager log
@@ -286,17 +266,37 @@ private
286
266
  })
287
267
  puts
288
268
 
289
- # Print pre-to-post diff.
290
- puts execute(%{
291
- #{escape command_path('git')} --no-pager diff
292
- --color=always
293
- #{escape pre_plaintext.path}
294
- #{escape post_plaintext.path}
295
- })
269
+ # See which files changed in this commit.
270
+ changed = wc(commit, files).split("\0")
271
+
272
+ changed.each do |file|
273
+ suffix = "-#{File.basename(file)}"
274
+
275
+ # Get plaintext "post" image.
276
+ tempfiles.push(post = temp_write(show(file, commit)))
277
+ tempfiles.push(post_plaintext = temp_write(
278
+ post.size.zero? ? '' : gpg_decrypt(post.path, '-'),
279
+ suffix
280
+ ))
281
+
282
+ # Get plaintext "pre" image.
283
+ tempfiles.push(pre = temp_write(show(file, "#{commit}~")))
284
+ tempfiles.push(pre_plaintext = temp_write(
285
+ pre.size.zero? ? '' : gpg_decrypt(pre.path, '-'),
286
+ suffix
287
+ ))
288
+
289
+ # Print pre-to-post diff.
290
+ puts execute(%{
291
+ #{escape command_path('git')} --no-pager diff
292
+ --color=always
293
+ #{escape pre_plaintext.path}
294
+ #{escape post_plaintext.path}
295
+ })
296
+ end
296
297
  puts
297
-
298
298
  ensure
299
- files.each do |tempfile|
299
+ tempfiles.each do |tempfile|
300
300
  tempfile.close
301
301
  tempfile.unlink
302
302
  end
@@ -304,6 +304,57 @@ private
304
304
  end
305
305
  end
306
306
 
307
+ def ls
308
+ matching.each { |file| puts file }
309
+ end
310
+
311
+ def status
312
+ exitstatus = 0
313
+ matching.each do |file|
314
+ pathname = Pathname.new(file)
315
+ basename = pathname.basename.to_s
316
+ outfile = pathname.dirname + basename.gsub(
317
+ /\A\.|\.#{EXTENSION}\z/, ''
318
+ )
319
+ if outfile.exist?
320
+ if FileUtils.uptodate?(outfile, [file])
321
+ # Plain-text is newer than ciphertext.
322
+ description = yellow('[MODIFIED]')
323
+ exitstatus |= STATUS['MODIFIED']
324
+ else
325
+ if (File.mtime(file) - File.mtime(outfile)) > 5
326
+ # Plain-text is signficantly older than ciphertext.
327
+ description = yellow('[STALE]')
328
+ exitstatus |= STATUS['STALE']
329
+ else
330
+ description = green('[OK]')
331
+ end
332
+ end
333
+ else
334
+ description = red('[MISSING]')
335
+ exitstatus |= STATUS['MISSING']
336
+ end
337
+ puts "#{file}: #{description}"
338
+ end
339
+ exit exitstatus
340
+ end
341
+
342
+ def matching
343
+ Dir.glob("**/*.#{EXTENSION}", File::FNM_DOTMATCH).reject do |candidate|
344
+ is_ignored?(candidate)
345
+ end
346
+ end
347
+
348
+ # Determine the appropriate mode for the given decrypted plaintext
349
+ # `file` based on its file extension.
350
+ def mode(file)
351
+ if EXECUTABLE_EXTENSIONS.include?(Pathname.new(file).extname)
352
+ 0700
353
+ else
354
+ 0600
355
+ end
356
+ end
357
+
307
358
  def normalize_option(option)
308
359
  normal = option.dup
309
360
 
@@ -318,17 +369,6 @@ private
318
369
  found
319
370
  end
320
371
 
321
- def preset
322
- passphrase = get_passphrase
323
- command = "#{escape command_path(gpg_preset_command)} --preset #{keygrip}"
324
- IO.popen(command, 'w+') do |io|
325
- io.puts passphrase
326
- io.close_write
327
- puts io.read # usually silent
328
- end
329
- die 'gpg-preset-passphrase failed' unless $?.success?
330
- end
331
-
332
372
  def process_args
333
373
  options, files = ARGV.partition { |arg| arg.start_with?('-') }
334
374
  subcommand = files.shift
@@ -351,17 +391,6 @@ private
351
391
  colorize(string, 31)
352
392
  end
353
393
 
354
- def require_agent
355
- unless ENV['GPG_AGENT_INFO']
356
- die <<-MSG
357
- GPG_AGENT_INFO not present in the environment.
358
- Try running this before retrying `#{command_name} #{@subcommand}`:
359
- eval $(gpg-agent --daemon)
360
- #{command_name} preset
361
- MSG
362
- end
363
- end
364
-
365
394
  def show(file, commit)
366
395
  # Redirect stderr to /dev/null because the file might not have existed prior
367
396
  # to this commit.
@@ -377,9 +406,8 @@ private
377
406
  doc.gsub(/^[ \t]{#{indent}}/, '')
378
407
  end
379
408
 
380
- def temp_write(contents)
381
- file = Tempfile.new('git-cipher-')
382
- file.chmod(0600)
409
+ def temp_write(contents, suffix = '')
410
+ file = Tempfile.new(['git-cipher-', suffix])
383
411
  file.write(contents)
384
412
  file.flush
385
413
  file
@@ -430,14 +458,6 @@ private
430
458
  #{command_name} encrypt -f
431
459
  #{command_name} encrypt --force # (alternative syntax)
432
460
  USAGE
433
- when 'forget'
434
- puts strip_heredoc(<<-USAGE)
435
- #{command_name} forget
436
-
437
- Forget passphrase previously stored using `#{command_name} preset`:
438
-
439
- #{command_name} forget
440
- USAGE
441
461
  when 'log'
442
462
  puts strip_heredoc(<<-USAGE)
443
463
  #{command_name} log FILE
@@ -447,13 +467,24 @@ private
447
467
 
448
468
  #{command_name} log foo
449
469
  USAGE
450
- when 'preset'
470
+ when 'ls'
471
+ puts strip_heredoc(<<-USAGE)
472
+ #{command_name} ls
473
+
474
+ Lists the encrypted files in the current directory and
475
+ its subdirectories.
476
+ USAGE
477
+ when 'status'
451
478
  puts strip_heredoc(<<-USAGE)
452
- #{command_name} preset
479
+ #{command_name} status
453
480
 
454
- Store a passphrase in a running `gpg-agent` agent:
481
+ Shows the status of encrypted files in the current directory and
482
+ its subdirectories.
455
483
 
456
- #{command_name} preset
484
+ Exits with status #{STATUS['MISSING']} if any decrypted file is missing (eg. "MISSING").
485
+ Exits with status #{STATUS['MODIFIED']} if any decrypted file has modifications (eg. "MODIFIED").
486
+ Exits with status #{STATUS['STALE']} if any encrypted file has modifications (eg. "STALE").
487
+ When multiple conditions apply, they are OR-ed together to produce a status code.
457
488
  USAGE
458
489
  else
459
490
  puts strip_heredoc(<<-USAGE)
@@ -461,14 +492,33 @@ private
461
492
 
462
493
  #{command_name} decrypt
463
494
  #{command_name} encrypt
464
- #{command_name} forget
465
495
  #{command_name} log
466
- #{command_name} preset
496
+ #{command_name} ls
497
+ #{command_name} status
467
498
  USAGE
468
499
  end
469
500
 
470
501
  exit
471
502
  end
503
+
504
+ # Show which files (out of `files`) changed in `commit`.
505
+ def wc(commit, files)
506
+ if files.nil?
507
+ execute(%{
508
+ #{escape command_path('git')} show --name-only --pretty=format: -z #{commit} --
509
+ **/.*.#{EXTENSION}
510
+ })
511
+ else
512
+ execute(%{
513
+ #{escape command_path('git')} show --name-only --pretty=format: -z #{commit} --
514
+ #{files.map { |f| escape(f) }.join(' ')}
515
+ })
516
+ end
517
+ end
518
+
519
+ def yellow(string)
520
+ colorize(string, 33)
521
+ end
472
522
  end
473
523
 
474
524
  Cipher.new.run
data/bin/git-cipher1 ADDED
@@ -0,0 +1,524 @@
1
+ #!/usr/bin/env ruby
2
+ # git-cipher -- encrypt/decrypt files
3
+
4
+ require 'fileutils'
5
+ require 'pathname'
6
+ require 'shellwords'
7
+ require 'tempfile'
8
+
9
+ class Cipher
10
+ EXTENSION = 'encrypted'
11
+ DEFAULT_GPG_USERS = 'greg@hurrell.net,wincent@github.com'
12
+ EXECUTABLE_EXTENSIONS = %w[.js .sh]
13
+ STATUS = {
14
+ 'MISSING' => 0b001,
15
+ 'MODIFIED' => 0b010,
16
+ 'STALE' => 0b100,
17
+ }
18
+ VALID_OPTIONS = %w[force help]
19
+ VALID_SUBCOMMANDS = %w[decrypt encrypt help log ls status]
20
+
21
+ def run
22
+ send @subcommand
23
+ end
24
+
25
+ private
26
+
27
+ def initialize
28
+ @subcommand, @options, @files = process_args
29
+
30
+ if @options.include?('help') || @subcommand == 'help'
31
+ usage(@subcommand)
32
+ end
33
+
34
+ @force = @options.include?('force')
35
+ end
36
+
37
+ def blue(string)
38
+ colorize(string, 34)
39
+ end
40
+
41
+ def check_ignored(path)
42
+ puts "[warning: #{path} is not ignored]" unless is_ignored?(path)
43
+ end
44
+
45
+ def colorize(string, color)
46
+ "\e[#{color}m#{string}\e[0m"
47
+ end
48
+
49
+ def command_name
50
+ @command_name ||= begin
51
+ if `ps -p #{Process.ppid.to_i}` =~ /\bgit cipher\b/
52
+ 'git cipher'
53
+ else
54
+ File.basename(__FILE__)
55
+ end
56
+ rescue
57
+ File.basename(__FILE__)
58
+ end
59
+ end
60
+
61
+ def command_path(command)
62
+ path = `sh -c command\\ -v\\ #{escape command}`.chomp
63
+ die "required dependency #{command} not found" if path.empty?
64
+ path
65
+ end
66
+
67
+ def decrypt
68
+ if @files.empty?
69
+ puts 'No explicit paths supplied: decrypting all matching files'
70
+ matching.each { |file| decrypt!(file) }
71
+ else
72
+ @files.each { |file| decrypt!(file) }
73
+ end
74
+ end
75
+
76
+ def decrypt!(file)
77
+ pathname = Pathname.new(file)
78
+ basename = pathname.basename.to_s
79
+ unless basename.start_with?('.')
80
+ die "#{file} does not begin with a period"
81
+ end
82
+
83
+ unless basename.end_with?(".#{EXTENSION}")
84
+ die "#{file} does not have an .#{EXTENSION} extension"
85
+ end
86
+
87
+ unless pathname.exist?
88
+ die "#{file} does not exist"
89
+ end
90
+
91
+ outfile = pathname.dirname + basename.gsub(
92
+ /\A\.|\.#{EXTENSION}\z/, ''
93
+ )
94
+
95
+ print "#{file} -> #{outfile} "
96
+
97
+ if FileUtils.uptodate?(outfile, [file]) && !@force
98
+ # decrypted is newer than encrypted; it might have local changes which we
99
+ # could blow away, so warn
100
+ puts red('[warning: plain-text newer than ciphertext; skipping]')
101
+ else
102
+ print green('[decrypting ...')
103
+ gpg_decrypt(file, outfile)
104
+ if $?.success?
105
+ puts green(' done]')
106
+
107
+ File.chmod(mode(outfile), outfile)
108
+
109
+ # Mark plain-text as older than ciphertext, this will prevent a
110
+ # bin/encrypt run from needlessly changing the contents of the ciphertext
111
+ # (as the encryption is non-deterministic).
112
+ time = File.mtime(file) - 1
113
+ File.utime(time, time, outfile)
114
+ else
115
+ print kill_line
116
+ puts red('[decrypting ... failed; bailing]')
117
+ exit $?.exitstatus
118
+ end
119
+
120
+ check_ignored(outfile)
121
+ end
122
+ end
123
+
124
+ def die(msg)
125
+ STDERR.puts red('error:'), strip_heredoc(msg)
126
+ exit 1
127
+ end
128
+
129
+ def encrypt
130
+ if @files.empty?
131
+ puts 'No explicit paths supplied: encrypting all matching files'
132
+ matching.each do |file|
133
+ file = Pathname.new(file)
134
+ encrypt!(
135
+ file.dirname +
136
+ file.basename.to_s.gsub(/\A\.|\.#{EXTENSION}\z/, '')
137
+ )
138
+ end
139
+ else
140
+ @files.each { |file| encrypt!(Pathname.new(file)) }
141
+ end
142
+ end
143
+
144
+ def encrypt!(file)
145
+ unless file.exist?
146
+ die "#{file} does not exist"
147
+ end
148
+
149
+ outfile = file.dirname + ".#{file.basename}.#{EXTENSION}"
150
+
151
+ print "#{file} -> #{outfile} "
152
+ if FileUtils.uptodate?(outfile, [file]) && !@force
153
+ puts blue('[up to date]')
154
+ else
155
+ recipients = gpg_users.
156
+ split(/\s*,\s*/).
157
+ map { |u| "--recipient #{escape u}"}.
158
+ join(' ')
159
+ print green('[encrypting ...')
160
+ execute(%{
161
+ #{escape command_path('gpg')}
162
+ --armor
163
+ --quiet
164
+ --batch
165
+ --no-tty
166
+ --yes
167
+ #{recipients}
168
+ --output #{escape outfile}
169
+ --encrypt #{escape file}
170
+ })
171
+ if $?.success?
172
+ puts green(' done]')
173
+ else
174
+ print kill_line
175
+ puts red('[encrypting ... failed; bailing]')
176
+ exit $?.exitstatus
177
+ end
178
+ end
179
+
180
+ File.chmod(mode(file), file)
181
+ check_ignored(file)
182
+ end
183
+
184
+ def escape(string)
185
+ Shellwords.escape(string)
186
+ end
187
+
188
+ def execute(string)
189
+ %x{#{string.gsub("\n", ' ')}}
190
+ end
191
+
192
+ def get_config(key)
193
+ value = `#{escape command_path('git')} config cipher.#{key}`.chomp
194
+ return if value.empty?
195
+ value
196
+ end
197
+
198
+ def gpg_decrypt(file, outfile)
199
+ execute(%{
200
+ #{escape command_path('gpg')}
201
+ -q
202
+ --yes
203
+ --batch
204
+ --no-tty
205
+ --use-agent
206
+ -o #{escape outfile}
207
+ -d #{escape file}
208
+ })
209
+ end
210
+
211
+ def gpg_users
212
+ ENV['GPG_USER'] || get_config('gpguser') || DEFAULT_GPG_USERS
213
+ end
214
+
215
+ def green(string)
216
+ colorize(string, 32)
217
+ end
218
+
219
+ def is_ignored?(path)
220
+ `#{escape command_path('git')} check-ignore -q #{escape path}`
221
+ return $?.exitstatus.zero?
222
+ end
223
+
224
+ def kill_line
225
+ # 2K deletes the line, 0G moves to column 0
226
+ # see: http://en.wikipedia.org/wiki/ANSI_escape_code
227
+ "\e[2K\e[0G"
228
+ end
229
+
230
+ def log
231
+ if @files.empty?
232
+ puts 'No explicit paths supplied: logging all matching files'
233
+ log!(nil)
234
+ else
235
+ log!(@files)
236
+ end
237
+ end
238
+
239
+ def log!(files)
240
+ if files.nil?
241
+ commits = execute(%{
242
+ #{escape command_path('git')} log
243
+ --pretty=format:%H
244
+ --topo-order
245
+ -- **/.*.#{EXTENSION}
246
+ }).split
247
+ else
248
+ # Would use `--follow` here, but that only works with a single file and, more
249
+ # importantly, all encrypted files look like random noise, so `--follow`
250
+ # won't do anything useful; log will stop at first rename.
251
+ commits = execute(%{
252
+ #{escape command_path('git')} log
253
+ --pretty=format:%H
254
+ --topo-order
255
+ -- #{files.map { |f| escape(f) }.join(' ')}
256
+ }).split
257
+ end
258
+
259
+ commits.each do |commit|
260
+ tempfiles = []
261
+ begin
262
+ # Print commit message.
263
+ puts execute(%{
264
+ #{escape command_path('git')} --no-pager log
265
+ --color=always -1 #{commit}
266
+ })
267
+ puts
268
+
269
+ # See which files changed in this commit.
270
+ changed = wc(commit, files).split("\0")
271
+
272
+ changed.each do |file|
273
+ suffix = "-#{File.basename(file)}"
274
+
275
+ # Get plaintext "post" image.
276
+ tempfiles.push(post = temp_write(show(file, commit)))
277
+ tempfiles.push(post_plaintext = temp_write(
278
+ post.size.zero? ? '' : gpg_decrypt(post.path, '-'),
279
+ suffix
280
+ ))
281
+
282
+ # Get plaintext "pre" image.
283
+ tempfiles.push(pre = temp_write(show(file, "#{commit}~")))
284
+ tempfiles.push(pre_plaintext = temp_write(
285
+ pre.size.zero? ? '' : gpg_decrypt(pre.path, '-'),
286
+ suffix
287
+ ))
288
+
289
+ # Print pre-to-post diff.
290
+ puts execute(%{
291
+ #{escape command_path('git')} --no-pager diff
292
+ --color=always
293
+ #{escape pre_plaintext.path}
294
+ #{escape post_plaintext.path}
295
+ })
296
+ end
297
+ puts
298
+ ensure
299
+ tempfiles.each do |tempfile|
300
+ tempfile.close
301
+ tempfile.unlink
302
+ end
303
+ end
304
+ end
305
+ end
306
+
307
+ def ls
308
+ matching.each { |file| puts file }
309
+ end
310
+
311
+ def status
312
+ exitstatus = 0
313
+ matching.each do |file|
314
+ pathname = Pathname.new(file)
315
+ basename = pathname.basename.to_s
316
+ outfile = pathname.dirname + basename.gsub(
317
+ /\A\.|\.#{EXTENSION}\z/, ''
318
+ )
319
+ if outfile.exist?
320
+ if FileUtils.uptodate?(outfile, [file])
321
+ # Plain-text is newer than ciphertext.
322
+ description = yellow('[MODIFIED]')
323
+ exitstatus |= STATUS['MODIFIED']
324
+ else
325
+ if (File.mtime(file) - File.mtime(outfile)) > 5
326
+ # Plain-text is signficantly older than ciphertext.
327
+ description = yellow('[STALE]')
328
+ exitstatus |= STATUS['STALE']
329
+ else
330
+ description = green('[OK]')
331
+ end
332
+ end
333
+ else
334
+ description = red('[MISSING]')
335
+ exitstatus |= STATUS['MISSING']
336
+ end
337
+ puts "#{file}: #{description}"
338
+ end
339
+ exit exitstatus
340
+ end
341
+
342
+ def matching
343
+ Dir.glob("**/*.#{EXTENSION}", File::FNM_DOTMATCH).reject do |candidate|
344
+ is_ignored?(candidate)
345
+ end
346
+ end
347
+
348
+ # Determine the appropriate mode for the given decrypted plaintext
349
+ # `file` based on its file extension.
350
+ def mode(file)
351
+ if EXECUTABLE_EXTENSIONS.include?(Pathname.new(file).extname)
352
+ 0700
353
+ else
354
+ 0600
355
+ end
356
+ end
357
+
358
+ def normalize_option(option)
359
+ normal = option.dup
360
+
361
+ if normal.sub!(/\A--/, '') # long option
362
+ found = VALID_OPTIONS.find { |o| o == normal }
363
+ elsif normal.sub!(/\A-/, '') # short option
364
+ found = VALID_OPTIONS.find { |o| o[0] == normal }
365
+ end
366
+
367
+ die "unrecognized option: #{option}" if found.nil?
368
+
369
+ found
370
+ end
371
+
372
+ def process_args
373
+ options, files = ARGV.partition { |arg| arg.start_with?('-') }
374
+ subcommand = files.shift
375
+
376
+ options.map! { |option| normalize_option(option) }
377
+
378
+ unless VALID_SUBCOMMANDS.include?(subcommand)
379
+ if subcommand.nil?
380
+ message = 'no subcommand'
381
+ else
382
+ message = 'unrecognized subcommand'
383
+ end
384
+ die [message, "expected one of #{VALID_SUBCOMMANDS.inspect}"].join(': ')
385
+ end
386
+
387
+ [subcommand, options, files]
388
+ end
389
+
390
+ def red(string)
391
+ colorize(string, 31)
392
+ end
393
+
394
+ def show(file, commit)
395
+ # Redirect stderr to /dev/null because the file might not have existed prior
396
+ # to this commit.
397
+ execute(%{
398
+ #{escape command_path('git')} show
399
+ #{commit}:#{escape file} 2> /dev/null
400
+ })
401
+ end
402
+
403
+ def strip_heredoc(doc)
404
+ # based on method of same name from Rails
405
+ indent = doc.scan(/^[ \t]*(?=\S)/).map(&:size).min || 0
406
+ doc.gsub(/^[ \t]{#{indent}}/, '')
407
+ end
408
+
409
+ def temp_write(contents, suffix = '')
410
+ file = Tempfile.new(['git-cipher-', suffix])
411
+ file.write(contents)
412
+ file.flush
413
+ file
414
+ end
415
+
416
+ # Print usage information and exit.
417
+ def usage(subcommand)
418
+ case subcommand
419
+ when 'decrypt'
420
+ puts strip_heredoc(<<-USAGE)
421
+ #{command_name} decrypt [-f|--force] [FILES...]
422
+
423
+ Decrypts files that have been encrypted for storage in version control
424
+
425
+ Decrypt two files, but only if the corresponding plain-text files
426
+ are missing or older:
427
+
428
+ #{command_name} decrypt .foo.encrypted .bar.encrypted
429
+
430
+ Decrypt all decryptable files:
431
+
432
+ #{command_name} decrypt
433
+
434
+ (Re-)decrypt all decryptable files, even those whose corresponding
435
+ plain-text files are newer:
436
+
437
+ #{command_name} decrypt -f
438
+ #{command_name} decrypt --force # (alternative syntax)
439
+ USAGE
440
+ when 'encrypt'
441
+ puts strip_heredoc(<<-USAGE)
442
+ #{command_name} encrypt [-f|--force] [FILES...]
443
+
444
+ Encrypts files for storage in version control
445
+
446
+ Encrypt two files, but only if the corresponding ciphertext files
447
+ are missing or older:
448
+
449
+ #{command_name} encrypt foo bar
450
+
451
+ Encrypt all encryptable files:
452
+
453
+ #{command_name} encrypt
454
+
455
+ (Re-)encrypt all encryptable files, even those whose corresponding
456
+ ciphertext files are newer:
457
+
458
+ #{command_name} encrypt -f
459
+ #{command_name} encrypt --force # (alternative syntax)
460
+ USAGE
461
+ when 'log'
462
+ puts strip_heredoc(<<-USAGE)
463
+ #{command_name} log FILE
464
+
465
+ Shows the log message and decrypted diff for FILE
466
+ (analogous to `git log -p -- FILE`).
467
+
468
+ #{command_name} log foo
469
+ USAGE
470
+ when 'ls'
471
+ puts strip_heredoc(<<-USAGE)
472
+ #{command_name} ls
473
+
474
+ Lists the encrypted files in the current directory and
475
+ its subdirectories.
476
+ USAGE
477
+ when 'status'
478
+ puts strip_heredoc(<<-USAGE)
479
+ #{command_name} status
480
+
481
+ Shows the status of encrypted files in the current directory and
482
+ its subdirectories.
483
+
484
+ Exits with status #{STATUS['MISSING']} if any decrypted file is missing (eg. "MISSING").
485
+ Exits with status #{STATUS['MODIFIED']} if any decrypted file has modifications (eg. "MODIFIED").
486
+ Exits with status #{STATUS['STALE']} if any encrypted file has modifications (eg. "STALE").
487
+ When multiple conditions apply, they are OR-ed together to produce a status code.
488
+ USAGE
489
+ else
490
+ puts strip_heredoc(<<-USAGE)
491
+ Available commands (invoke any with -h or --help for more info):
492
+
493
+ #{command_name} decrypt
494
+ #{command_name} encrypt
495
+ #{command_name} log
496
+ #{command_name} ls
497
+ #{command_name} status
498
+ USAGE
499
+ end
500
+
501
+ exit
502
+ end
503
+
504
+ # Show which files (out of `files`) changed in `commit`.
505
+ def wc(commit, files)
506
+ if files.nil?
507
+ execute(%{
508
+ #{escape command_path('git')} show --name-only --pretty=format: -z #{commit} --
509
+ **/.*.#{EXTENSION}
510
+ })
511
+ else
512
+ execute(%{
513
+ #{escape command_path('git')} show --name-only --pretty=format: -z #{commit} --
514
+ #{files.map { |f| escape(f) }.join(' ')}
515
+ })
516
+ end
517
+ end
518
+
519
+ def yellow(string)
520
+ colorize(string, 33)
521
+ end
522
+ end
523
+
524
+ Cipher.new.run
data/bin/git-cipher2 ADDED
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # TODO: in readme, note threat model and how this is "rolling your own crypto"
4
+ # even though you might not think that "using openssl" is "rolling your own
5
+ # crypto"; therefore this is for protecting LOW VALUE secrets (eg. hostnames in
6
+ # an ssh config file, not cryptocurrency secret keys)
7
+ #
8
+ # TODO: think about how dotfiles work. in absence of key, plaintext file is
9
+ # absent, so we know not to install it... with clean/smudge filters, file is
10
+ # always present, so we need another way of deciding whether or not to install
11
+ # (may need a header line or stash the whole thing in JSON)
12
+ # given that we're going to do encrypt-then-mac, maybe the trailer can be the
13
+ # signal? ie. we'll have something like:
14
+ #
15
+ # U2FsdGVkX1/wDfrOAAAAAJyY66rhru4B0gIY0axns4oLgCEN2Og73UvGzFfCzs8a
16
+ # H/NRK/4MhpbXNhvoVg/Is5UgrZqtMKs96UoAOtYtzTJweByMhFjaTpyK9Dtz7ctc
17
+ # ...
18
+ # UqEPjxtbf4LeUHofwbIXvVzMNqdWrjvpV1wUkaOcHdeDUwUMzcPmct7ZpjEPcSDW
19
+ # nPfzqyYDoxyT9waSKbYlBQ==
20
+ # HMAC-SHA256(/etc/passwd)= 0c967cc4b8b040dc7c4185f03247bfbeaa7403f626f8fd9c88cec0d3de86ce95
21
+ #
22
+ # TODO: links
23
+ # https://stackoverflow.com/questions/16056135/how-to-use-openssl-to-encrypt-decrypt-files/31552829
24
+ # https://security.stackexchange.com/questions/3959/recommended-of-iterations-when-using-pbkdf2-sha256/3993
25
+ # https://www.daemonology.net/blog/2009-06-24-encrypt-then-mac.html
26
+ # > Encrypt-and-MAC: The ciphertext is generated by encrypting the plaintext and then appending a MAC of the plaintext. This is approximately how SSH works.
27
+ # > MAC-then-encrypt: The ciphertext is generated by appending a MAC to the plaintext and then encrypting everything. This is approximately how SSL works.
28
+ # > Encrypt-then-MAC: The ciphertext is generated by encrypting the plaintext and then appending a MAC of the encrypted plaintext. This is approximately how IPSEC works.
29
+ # https://crypto.stackexchange.com/questions/202/should-we-mac-then-encrypt-or-encrypt-then-mac
30
+ #
31
+ # TODO: prior art
32
+ # https://github.com/AGWA/git-crypt
33
+ # https://github.com/StackExchange/blackbox#how-is-the-encryption-done
34
+ # https://github.com/elasticdog/transcrypt
35
+ # https://github.com/sobolevn/git-secret
36
+ # (by no means an exhaustive list... there are so many of them...)
37
+ class Cipher
38
+ VALID_SUBCOMMANDS = %w[
39
+ add
40
+ help
41
+ init
42
+ list
43
+ log
44
+ ls
45
+ register
46
+ remove
47
+ rm
48
+ status
49
+ unregister
50
+ ]
51
+
52
+ def run
53
+ end
54
+
55
+ private
56
+
57
+ def initialize
58
+ @subcommand, @options, @files = process_args
59
+ end
60
+
61
+ def colorize(string, color)
62
+ "\e[#{color}m#{string}\e[0m"
63
+ end
64
+
65
+ def die(message)
66
+ STDERR.puts red('error:'), dedent(message)
67
+ exit 1
68
+ end
69
+
70
+ # TODO need to do encrypt-then-mac https://stackoverflow.com/a/22958889/2103996
71
+ def encrypt
72
+ ENV['ENC_PASS'] = 'encryption pass'
73
+ # note -S must be hex digits
74
+ # can do cipher instead of enc -e cipher
75
+ # eg. `openssl aes-256-cbc` instead of `openssl enc -e -aes-256-cbc`
76
+ # -pass stdin could also work
77
+ # openssl enc -e cipher -md sha1 -pass env:ENC_PASS -S $salt -in $infile | xxd -p
78
+ # openssl enc -e cipher -md sha1 -pass env:ENC_PASS -S $salt -in $infile | openssl base64
79
+ # openssl enc -e cipher -base64 -md sha1 -pass env:ENC_PASS -S $salt -in $infile
80
+ #
81
+ # only concern here is that iv won't be random as it comes from pass?
82
+ # -P will print out the deets here; this shows that when you call with same
83
+ # salt and pass, you get same key and same iv
84
+ # when you alter the pass, you get a different key and iv
85
+ # when you alter the salt, you get a different key and iv
86
+ #
87
+ # $ openssl enc -e -aes-256-cbc -base64 -md sha1 -pass pass:secret -S f00dface -in /etc/passwd -P
88
+ # salt=F00DFACE00000000
89
+ # key=2CB12DA63001EA3474724000D66FE00ACE073DE41364108ADFE84095280693B9
90
+ # iv =A18D6BA58FD1916C40E2AB674F575360
91
+ # $ openssl enc -e -aes-256-cbc -base64 -md sha1 -pass pass:secret -S f00dface -in /etc/passwd -P
92
+ # salt=F00DFACE00000000
93
+ # key=2CB12DA63001EA3474724000D66FE00ACE073DE41364108ADFE84095280693B9
94
+ # iv =A18D6BA58FD1916C40E2AB674F575360
95
+ # $ openssl enc -e -aes-256-cbc -base64 -md sha1 -pass pass:secret2 -S f00dface -in /etc/passwd -P
96
+ # salt=F00DFACE00000000
97
+ # key=AED814B111F3732FBEA095B1E66F2958D3D92E4C00A3ABBCB7FD260515C44E92
98
+ # iv =D9D9F867C730925ED0C8CA8252CAF5C2
99
+ # $ openssl enc -e -aes-256-cbc -base64 -md sha1 -pass pass:secret2 -S f00dfacef -in /etc/passwd -P
100
+ # salt=F00DFACEF0000000
101
+ # key=04227E47F87C2E8876714976EA5A69B58CB36CD5404D4F60271411EDD2BD076D
102
+ # iv =A7F83B902B5CA76A8B04AF5652C3B799
103
+ #
104
+ # using stronger (slower) key derivation with -pbkdf2 -iter 20000
105
+ # openssl enc -e -aes-256-cbc -base64 -pbkdf2 -iter 20000 -pass pass:secret -S f00dface -in /etc/passwd -P
106
+ # (doesn't work on macOS version of openssl; testing on linux two times in a row we get)
107
+ # `openssl version` on linux prints: OpenSSL 1.1.1q 5 Jul 2022
108
+ # `openssl version` on macOS prints: LibreSSL 2.8.3
109
+ # install openssl on macOS is probably not a good idea: https://stackoverflow.com/questions/22795471/utilizing-pbkdf2-with-openssl-library
110
+ #
111
+ # hex string is too short, padding with zero bytes to length
112
+ # salt=F00DFACE00000000
113
+ # key=D661D07A0672957F64646190C7BF8E6FAEA13C97830E7C036B6C978CD6C89FC7
114
+ # iv =82BD1988A8E0F4D3E48C40AB366D4174
115
+ #
116
+ # hex string is too short, padding with zero bytes to length
117
+ # salt=F00DFACE00000000
118
+ # key=D661D07A0672957F64646190C7BF8E6FAEA13C97830E7C036B6C978CD6C89FC7
119
+ # iv =82BD1988A8E0F4D3E48C40AB366D4174
120
+ #
121
+ # (ie. is consistent across runs at least)
122
+ #
123
+ # is using sha1 for key derivation a problem? https://security.stackexchange.com/questions/3959/recommended-of-iterations-when-using-pbkdf2-sha256/3993
124
+ # probably not, because we're not using a user-supplied password; we're using an extremely high entropy long password
125
+ #
126
+ # $ openssl enc -e -aes-256-cbc -base64 -md sha1 -pass pass:extremely_long_secret_that_will_actually_be_total_gibberish_we_can_even_pipe_in_binary_crap_to_std_in -S f00dface -in /etc/passwd -P
127
+ # salt=F00DFACE00000000
128
+ # key=191531DB1684CFE9D15FBCC1F9D6E7A384E4EC08CDA22B596D6AF83F32113720
129
+ # iv =738D9C01A176CF97B90F48466188C5FA
130
+ #
131
+ # note we can use -md sha256 and that works on macOS and Linux
132
+ # cf node: https://stackoverflow.com/questions/12219499/whats-wrong-with-nodejs-crypto-decipher
133
+ # that says i can avoid the salt being prepended... interesting... -nosalt
134
+ # it is not documented in the linux openssl manpage (see `man enc` instead), but it works in both places and
135
+ # produces the same results, byte for byte
136
+ # macOS manpage says:
137
+ # > This option should never be used since it makes it possible to perform
138
+ # > efficient dictionary attacks on the password and to attack stream cipher
139
+ # > encrypted data.
140
+ # linux:
141
+ # > Don't use a salt in the key derivation routines. This option SHOULD NOT
142
+ # > be used except for test purposes or compatibility with ancient versions of
143
+ # > OpenSSL.
144
+
145
+
146
+
147
+ end
148
+
149
+ # need something random (salt) as input into cbc mode to make it secure
150
+ # we can't literally use something like this, because git-cipher **does not use passwords**
151
+ # we need to use GPG to read password, _then_ blah...
152
+ #
153
+ # note that salt string must be hex, 16 digits long (if shorter, it gets
154
+ # padded, if longer, we get an error); equivalent to 64 bits
155
+ #
156
+ # note that salt is derived from filename + password (from GPG) + file contents
157
+ # which means two files can have same contents and will get different salt
158
+ # and any time file changes, it gets a new salt
159
+ def salt
160
+ # $key = "$filename:$password"
161
+ # echo hello | openssl dgst -hmac $key -sha256
162
+ # prints: 8e384ff349a3d90f2c7837b0d76de8f81c6e85b390ed38905f521ae77cb9a29a
163
+ # or: openssl dgst -hmac $key -sha256 $infile
164
+ # prints: HMAC-SHA256($infile)= 0c967cc4b8b040dc7c4185f03247bfbeaa7403f626f8fd9c88cec0d3de86ce95
165
+ end
166
+
167
+ def process_args
168
+ options, files = ARGV.partition { |arg| arg.start_with?('-') }
169
+ subcommand = files.shift
170
+
171
+ unless VALID_SUBCOMMANDS.include?(subcommand)
172
+ if subcommand.nil?
173
+ message = 'no subcommand'
174
+ else
175
+ message = "unrecognized subcommand #{subcommand.inspect}"
176
+ end
177
+ die [message, "expected one of #{VALID_SUBCOMMANDS.inspect}"].join(': ')
178
+ end
179
+
180
+ [subcommand, options, files]
181
+ end
182
+
183
+ def red(string)
184
+ colorize(string, 31)
185
+ end
186
+
187
+ # Based on the strip_heredoc method in Rails.
188
+ #
189
+ # See: https://apidock.com/rails/String/strip_heredoc
190
+ def dedent(doc)
191
+ indent = doc.scan(/^[ \t]*(?=\S)/).map(&:size).min || 0
192
+ doc.gsub(/^[ \t]{#{indent}}/, '')
193
+ end
194
+ end
195
+
196
+ Cipher.new.run
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: git-cipher
3
3
  version: !ruby/object:Gem::Version
4
- version: '0.2'
4
+ version: '1.1'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Greg Hurrell
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-02-08 00:00:00.000000000 Z
11
+ date: 2022-08-02 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: "\n Provides a convenient workflow for working with encrypted files
14
14
  in a public\n Git repo. Delegates the underlying work of encryption/decryption
@@ -21,30 +21,31 @@ extensions: []
21
21
  extra_rdoc_files: []
22
22
  files:
23
23
  - bin/git-cipher
24
+ - bin/git-cipher1
25
+ - bin/git-cipher2
24
26
  homepage: https://github.com/wincent/git-cipher
25
27
  licenses:
26
28
  - MIT
27
29
  metadata: {}
28
- post_install_message:
30
+ post_install_message:
29
31
  rdoc_options: []
30
32
  require_paths:
31
33
  - lib
32
34
  required_ruby_version: !ruby/object:Gem::Requirement
33
35
  requirements:
34
- - - '>='
36
+ - - ">="
35
37
  - !ruby/object:Gem::Version
36
38
  version: '0'
37
39
  required_rubygems_version: !ruby/object:Gem::Requirement
38
40
  requirements:
39
- - - '>='
41
+ - - ">="
40
42
  - !ruby/object:Gem::Version
41
43
  version: '0'
42
44
  requirements:
43
45
  - Git
44
46
  - GnuPG
45
- rubyforge_project:
46
- rubygems_version: 2.0.14
47
- signing_key:
47
+ rubygems_version: 3.3.15
48
+ signing_key:
48
49
  specification_version: 4
49
50
  summary: Manages encrypted content in a Git repo
50
51
  test_files: []