git-cipher 0.2 → 1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/bin/git-cipher +170 -120
- data/bin/git-cipher1 +524 -0
- data/bin/git-cipher2 +196 -0
- metadata +10 -9
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 7d5b65afd443605f7782cec24d66093e353ac155e6b4a1e90aac40da2b8cbfbd
|
4
|
+
data.tar.gz: ab7448447f5ccd3b6467190315e9597e082ae01880ffcdb4cd0e3542b37b0b3e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
12
|
-
|
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
|
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
|
-
|
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
|
-
|
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(
|
107
|
+
File.chmod(mode(outfile), outfile)
|
106
108
|
|
107
|
-
#
|
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
|
-
|
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
|
-
|
157
|
-
|
162
|
+
--armor
|
163
|
+
--quiet
|
158
164
|
--batch
|
159
165
|
--no-tty
|
160
166
|
--yes
|
161
|
-
|
162
|
-
|
163
|
-
|
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(
|
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
|
221
|
-
ENV['
|
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
|
233
|
-
|
234
|
-
|
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
|
-
|
233
|
+
log!(nil)
|
254
234
|
else
|
255
|
-
|
235
|
+
log!(@files)
|
256
236
|
end
|
257
237
|
end
|
258
238
|
|
259
|
-
def log!(
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
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
|
-
|
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
|
-
#
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
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
|
-
|
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 '
|
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}
|
479
|
+
#{command_name} status
|
453
480
|
|
454
|
-
|
481
|
+
Shows the status of encrypted files in the current directory and
|
482
|
+
its subdirectories.
|
455
483
|
|
456
|
-
|
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}
|
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: '
|
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:
|
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
|
-
|
46
|
-
|
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: []
|