git-cipher 0.2 → 1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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: []
|