git-cipher 1.0 → 1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (5) hide show
  1. checksums.yaml +5 -5
  2. data/bin/git-cipher +102 -60
  3. data/bin/git-cipher1 +524 -0
  4. data/bin/git-cipher2 +196 -0
  5. metadata +8 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 4c0501f05ef3eb618fc318e776e838a0919727fb
4
- data.tar.gz: 2af67cbad13cae60e002fb333c8e9e863cdcf7b1
2
+ SHA256:
3
+ metadata.gz: 7d5b65afd443605f7782cec24d66093e353ac155e6b4a1e90aac40da2b8cbfbd
4
+ data.tar.gz: ab7448447f5ccd3b6467190315e9597e082ae01880ffcdb4cd0e3542b37b0b3e
5
5
  SHA512:
6
- metadata.gz: 05b0f6b6fcb1fabac7fb6250d84a90ee06a7382bf9f03830d9a769666075819d291b0bd0deb3706f71a3481797002e7f163f343111caa103a003516f7f05e98c
7
- data.tar.gz: 1be1b9e1ae34640beab077d9f1a12fc7ed14a6c391ee94683ccf166f53fc02c3e8720c8f7a52a0b6096792e670a79f1b4419d5b97789ea4a6336aa2f9cf67e01
6
+ metadata.gz: 2dce18072bcee778726a04b35d2103552ba51c860b669428a7ce4c5f9c9c65e49866b6092f1d416b9b88b417a5d1c696be78d8f880b07e87ba71f2d75f0c6e8b
7
+ data.tar.gz: 91c20158875f8f66ebeb81924a8518f1df9265a1324dd8e9f8bf45ecb6d1787320194229c6e839525cbfc40b4fe622bc46a4e54403cbbb20d5264a8e2fb284fc
data/bin/git-cipher CHANGED
@@ -8,11 +8,12 @@ require 'tempfile'
8
8
 
9
9
  class Cipher
10
10
  EXTENSION = 'encrypted'
11
- DEFAULT_GPG_USER = 'greg@hurrell.net'
11
+ DEFAULT_GPG_USERS = 'greg@hurrell.net,wincent@github.com'
12
12
  EXECUTABLE_EXTENSIONS = %w[.js .sh]
13
13
  STATUS = {
14
- 'MISSING' => 0b01,
15
- 'MODIFIED' => 0b10,
14
+ 'MISSING' => 0b001,
15
+ 'MODIFIED' => 0b010,
16
+ 'STALE' => 0b100,
16
17
  }
17
18
  VALID_OPTIONS = %w[force help]
18
19
  VALID_SUBCOMMANDS = %w[decrypt encrypt help log ls status]
@@ -38,8 +39,7 @@ private
38
39
  end
39
40
 
40
41
  def check_ignored(path)
41
- `#{escape command_path('git')} check-ignore -q #{escape path}`
42
- puts "[warning: #{path} is not ignored]" unless $?.exitstatus.zero?
42
+ puts "[warning: #{path} is not ignored]" unless is_ignored?(path)
43
43
  end
44
44
 
45
45
  def colorize(string, color)
@@ -59,7 +59,7 @@ private
59
59
  end
60
60
 
61
61
  def command_path(command)
62
- path = `command -v #{escape command}`.chomp
62
+ path = `sh -c command\\ -v\\ #{escape command}`.chomp
63
63
  die "required dependency #{command} not found" if path.empty?
64
64
  path
65
65
  end
@@ -152,17 +152,21 @@ private
152
152
  if FileUtils.uptodate?(outfile, [file]) && !@force
153
153
  puts blue('[up to date]')
154
154
  else
155
+ recipients = gpg_users.
156
+ split(/\s*,\s*/).
157
+ map { |u| "--recipient #{escape u}"}.
158
+ join(' ')
155
159
  print green('[encrypting ...')
156
160
  execute(%{
157
161
  #{escape command_path('gpg')}
158
- -a
159
- -q
162
+ --armor
163
+ --quiet
160
164
  --batch
161
165
  --no-tty
162
166
  --yes
163
- -r #{escape gpg_user}
164
- -o #{escape outfile}
165
- -e #{escape file}
167
+ #{recipients}
168
+ --output #{escape outfile}
169
+ --encrypt #{escape file}
166
170
  })
167
171
  if $?.success?
168
172
  puts green(' done]')
@@ -191,16 +195,6 @@ private
191
195
  value
192
196
  end
193
197
 
194
- def get_passphrase
195
- stty = escape(command_path('stty'))
196
- print 'Passphrase [will not be echoed]: '
197
- `#{stty} -echo` # quick hack, cheaper than depending on highline gem
198
- STDIN.gets.chomp
199
- ensure
200
- `#{stty} echo`
201
- puts
202
- end
203
-
204
198
  def gpg_decrypt(file, outfile)
205
199
  execute(%{
206
200
  #{escape command_path('gpg')}
@@ -214,14 +208,19 @@ private
214
208
  })
215
209
  end
216
210
 
217
- def gpg_user
218
- ENV['GPG_USER'] || get_config('gpguser') || DEFAULT_GPG_USER
211
+ def gpg_users
212
+ ENV['GPG_USER'] || get_config('gpguser') || DEFAULT_GPG_USERS
219
213
  end
220
214
 
221
215
  def green(string)
222
216
  colorize(string, 32)
223
217
  end
224
218
 
219
+ def is_ignored?(path)
220
+ `#{escape command_path('git')} check-ignore -q #{escape path}`
221
+ return $?.exitstatus.zero?
222
+ end
223
+
225
224
  def kill_line
226
225
  # 2K deletes the line, 0G moves to column 0
227
226
  # see: http://en.wikipedia.org/wiki/ANSI_escape_code
@@ -230,37 +229,36 @@ private
230
229
 
231
230
  def log
232
231
  if @files.empty?
233
- # TODO: would be nice to interleave these instead of doing them serially.
234
232
  puts 'No explicit paths supplied: logging all matching files'
235
- matching.each { |file| log!(file) }
233
+ log!(nil)
236
234
  else
237
- @files.each { |file| log!(file) }
235
+ log!(@files)
238
236
  end
239
237
  end
240
238
 
241
- def log!(file)
242
- commits = execute(%{
243
- #{escape command_path('git')} log
244
- --pretty=format:%H -- #{escape file}
245
- }).split
246
- suffix = "-#{File.basename(file)}"
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
247
258
 
248
259
  commits.each do |commit|
249
- files = []
260
+ tempfiles = []
250
261
  begin
251
- # Get plaintext "post" image.
252
- files.push(post = temp_write(show(file, commit)))
253
- files.push(
254
- post_plaintext = temp_write(gpg_decrypt(post.path, '-'), suffix)
255
- )
256
-
257
- # Get plaintext "pre" image.
258
- files.push(pre = temp_write(show(file, "#{commit}~")))
259
- files.push(pre_plaintext = temp_write(
260
- pre.size.zero? ? '' : gpg_decrypt(pre.path, '-'),
261
- suffix
262
- ))
263
-
264
262
  # Print commit message.
265
263
  puts execute(%{
266
264
  #{escape command_path('git')} --no-pager log
@@ -268,17 +266,37 @@ private
268
266
  })
269
267
  puts
270
268
 
271
- # Print pre-to-post diff.
272
- puts execute(%{
273
- #{escape command_path('git')} --no-pager diff
274
- --color=always
275
- #{escape pre_plaintext.path}
276
- #{escape post_plaintext.path}
277
- })
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
278
297
  puts
279
-
280
298
  ensure
281
- files.each do |tempfile|
299
+ tempfiles.each do |tempfile|
282
300
  tempfile.close
283
301
  tempfile.unlink
284
302
  end
@@ -304,7 +322,13 @@ private
304
322
  description = yellow('[MODIFIED]')
305
323
  exitstatus |= STATUS['MODIFIED']
306
324
  else
307
- description = green('[OK]')
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
308
332
  end
309
333
  else
310
334
  description = red('[MISSING]')
@@ -316,7 +340,9 @@ private
316
340
  end
317
341
 
318
342
  def matching
319
- Dir.glob("**/*.#{EXTENSION}", File::FNM_DOTMATCH)
343
+ Dir.glob("**/*.#{EXTENSION}", File::FNM_DOTMATCH).reject do |candidate|
344
+ is_ignored?(candidate)
345
+ end
320
346
  end
321
347
 
322
348
  # Determine the appropriate mode for the given decrypted plaintext
@@ -455,9 +481,10 @@ private
455
481
  Shows the status of encrypted files in the current directory and
456
482
  its subdirectories.
457
483
 
458
- Exits with status #{STATUS['MISSING']} if any decrypted file is missing.
459
- Exits with status #{STATUS['MODIFIED']} if any decrypted file is modified.
460
- Exits with status #{STATUS['MISSING'] | STATUS['MODIFIED']} if both of the above apply.
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.
461
488
  USAGE
462
489
  else
463
490
  puts strip_heredoc(<<-USAGE)
@@ -474,6 +501,21 @@ private
474
501
  exit
475
502
  end
476
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
+
477
519
  def yellow(string)
478
520
  colorize(string, 33)
479
521
  end
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: '1.0'
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: 2019-01-16 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,11 +21,13 @@ 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
@@ -42,9 +44,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
42
44
  requirements:
43
45
  - Git
44
46
  - GnuPG
45
- rubyforge_project:
46
- rubygems_version: 2.5.2.3
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: []