git-cipher 1.0 → 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 +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: []