gerrit.wikimedia.org
Gitiles
Code ReviewSign In
gerrit.wikimedia.org / operations / debs / wmf-sre-laptop / 4c9462f5bf9d64f990c8adc19f5e09f315dc4c75 / . / scripts / pws
blob: 6812f783232cf09171b8ad493548a481c4bf9065 [file] [log] [blame]
Moritz Mühlenhoff14759462020-04-07 12:02:30 +0200[diff] [blame]
1
#!/usr/bin/ruby
2
3
# password store management tool
4
5
# Copyright (c) 2008, 2009, 2011, 2013 Peter Palfrader <peter@palfrader.org>
6
# Copyright (c) 2014 Fastly
7
#
8
# Permission is hereby granted, free of charge, to any person obtaining
9
# a copy of this software and associated documentation files (the
10
# "Software"), to deal in the Software without restriction, including
11
# without limitation the rights to use, copy, modify, merge, publish,
12
# distribute, sublicense, and/or sell copies of the Software, and to
13
# permit persons to whom the Software is furnished to do so, subject to
14
# the following conditions:
15
#
16
# The above copyright notice and this permission notice shall be
17
# included in all copies or substantial portions of the Software.
18
#
19
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
20
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
21
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
22
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
23
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
24
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
25
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
26
27
require 'optparse'
28
require 'thread'
29
require 'tempfile'
30
31
require 'yaml'
32
Thread.abort_on_exception = true
33
34
GNUPG = "gpg"
35
GROUP_PATTERN = "@[a-zA-Z0-9_-]+"
36
USER_PATTERN = "[a-zA-Z0-9:_-]+"
37
$program_name = File.basename($0, '.*')
38
CONFIG_FILE = ENV['HOME']+ "/.pws.yaml"
39
40
$editor = ENV['EDITOR']
41
if $editor == nil
42
%w{/usr/bin/sensible-editor /usr/bin/editor /usr/bin/vi}.each do |editor|
43
if FileTest.executable?(editor)
44
$editor = editor
45
break
46
end
47
end
48
end
49
if $editor == nil
50
STDERR.puts "Cannot find an editor"
51
exit(1)
52
end
53
54
class GnuPG
55
@@my_keys = nil
56
@@my_fprs = nil
57
@@keyid_fpr_mapping = {}
58
@@extra_args = []
59
60
def GnuPG.extra_args=(val)
61
@@extra_args = val
62
end
63
64
def GnuPG.readwrite3(intxt, infd, stdoutfd, stderrfd, statusfd=nil)
65
outtxt, stderrtxt, statustxt = ''
66
thread_in = Thread.new {
67
infd.print intxt
68
infd.close
69
}
70
thread_out = Thread.new {
71
outtxt = stdoutfd.read
72
stdoutfd.close
73
}
74
thread_err = Thread.new {
75
errtxt = stderrfd.read
76
stderrfd.close
77
}
78
thread_status = Thread.new {
79
statustxt = statusfd.read
80
statusfd.close
81
} if (statusfd)
82
83
thread_in.join
84
thread_out.join
85
thread_err.join
86
thread_status.join if thread_status
87
88
return outtxt, stderrtxt, statustxt
89
end
90
91
def GnuPG.open3call(cmd, intxt, args, require_success = false, do_status=true)
92
inR, inW = IO.pipe
93
outR, outW = IO.pipe
94
errR, errW = IO.pipe
95
statR, statW = IO.pipe if do_status
96
97
pid = Kernel.fork do
98
inW.close
99
outR.close
100
errR.close
101
statR.close if do_status
102
STDIN.reopen(inR)
103
STDOUT.reopen(outW)
104
STDERR.reopen(errW)
105
fds = {
106
STDIN=>inR,
107
STDOUT=>outW,
108
STDERR=>errW,
109
}
110
begin
111
if do_status
112
fds[statW.fileno] = statW
113
exec(cmd, "--status-fd=#{statW.fileno}", *(@@extra_args + args), fds)
114
else
115
exec(cmd, *(@@extra_args + args), fds)
116
end
117
rescue Exception => e
118
outW​.​puts​(​"[PWSEXECERROR]: #{e}")
119
exit(1)
120
end
121
raise ("Calling gnupg failed")
122
end
123
inR.close
124
outW.close
125
errW.close
126
if do_status
127
statW.close
128
(outtxt, stderrtxt, statustxt) = readwrite3(intxt, inW, outR, errR, statR);
129
else
130
(outtxt, stderrtxt) = readwrite3(intxt, inW, outR, errR);
131
end
132
wpid, status = Process.waitpid2 pid
133
throw "Unexpected pid: #{pid} vs #{wpid}" unless pid == wpid
134
throw "Process has not exited!?" unless status.exited?
135
if (require_success and status.exitstatus != 0)
136
STDERR.puts "#{cmd} call did not exit sucessfully."
137
STDERR.puts "output on stdout:"
138
STDERR.puts outtxt
139
STDERR.puts "output on stderr:"
140
STDERR.puts stderrtxt
141
if do_status
142
STDERR.puts "output on statusfd:"
143
STDERR.puts statustxt
144
end
145
exit(1)
146
end
147
if m=/^\[PWSEXECERROR\]: (.*)/.match(outtxt) then
148
STDERR.puts "Could not run GnuPG: #{m[1]}"
149
exit(1)
150
end
151
if do_status
152
return outtxt, stderrtxt, statustxt, status.exitstatus
153
else
154
return outtxt, stderrtxt, status.exitstatus
155
end
156
end
157
158
def GnuPG.gpgcall(intxt, args, require_success = false)
159
return open3call(GNUPG, intxt, args, require_success)
160
end
161
162
def GnuPG.init_keys()
163
return if @@my_keys
164
(outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', %w{--fast-list-mode --with-colons --with-fingerprint --list-secret-keys}, true)
165
@@my_keys = []
166
@@my_fprs = []
167
outtxt.split("\n").each do |line|
168
parts = line.split(':')
169
if (parts[0] == "ssb" or parts[0] == "sec")
170
@@my_keys.push parts[4]
171
elsif (parts[0] == "fpr")
172
@@my_fprs.push parts[9]
173
end
174
end
175
end
176
# This is for my private keys, so we can tell if a file is encrypted to us
177
def GnuPG.get_my_keys()
178
init_keys
179
@@my_keys
180
end
181
# And this is for my private keys also, so we can tell if we are encrypting to ourselves
182
def GnuPG.get_my_fprs()
183
init_keys
184
@@my_fprs
185
end
186
187
# This maps public keyids to fingerprints, so we can figure
188
# out if a file that is encrypted to a bunch of keys is
189
# encrypted to the fingerprints it should be encrypted to
190
def GnuPG​.​get_fpr_from_keyid​(​keyid​)
191
fpr = @​@keyid_fpr_mapping​[​keyid​]
192
# this can be null, if we tried to find the fpr but failed to find the key in our keyring
193
unless fpr
194
STDERR.puts "Warning: No key found for keyid #{keyid}"
195
end
196
return fpr
197
end
198
def GnuPG​.​get_fprs_from_keyids​(​keyids​)
199
learn_fingerprints_from_keyids​(​keyids​)
200
return keyids.collect{ |k| get_fpr_from_keyid(k) or "unknown" }
201
end
202
203
# this is to load the keys we will soon be asking about into
204
# our keyid-fpr-mapping hash
205
def GnuPG​.​learn_fingerprints_from_keyids​(​keyids​)
206
need_to_learn = keyids.reject{ |k| @​@keyid_fpr_mapping​.​has_key​?(​k​) }
207
if need_to_learn.size > 0
208
# we can't use --fast-list-mode here because GnuPG is broken
209
# and does not show elmo's fingerprint in a call like
210
# gpg --with-colons --fast-list-mode --with-fingerprint --list-key D7C3F131AB2A91F5
211
args = %w{--with-colons --with-fingerprint --list-keys}
212
args​.​push​(​"--keyring=./.keyring"​, "--no-default-keyring") if FileTest.exists?(".keyring")
213
args.concat need_to_learn
214
(outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', args)
215
216
pub = nil
217
fpr = nil
218
outtxt.split("\n").each do |line|
219
parts = line.split(':')
220
if (parts[0] == "pub")
221
pub = parts[4]
222
fpr = nil
223
elsif (parts[0] == "fpr") and fpr.nil?
224
fpr = parts[9]
225
@@keyid_fpr_mapping[pub] = fpr
226
elsif (parts[0] == "sub")
227
@​@keyid_fpr_mapping​[​parts​[​4​]] = fpr
228
end
229
end
230
end
231
need_to_learn.reject{ |k| @​@keyid_fpr_mapping​.​has_key​?(​k​) }.each { |k| @@keyid_fpr_mapping[k] = nil }
232
end
233
end
234
235
def read_input(query, default_yes=true)
236
if default_yes
237
append = '[Y/n]'
238
else
239
append = '[y/N]'
240
end
241
242
while true
243
print "#{query} #{append} "
244
begin
245
i = STDIN​.​readline​.​chomp​.​downcase
246
rescue EOFError
247
return default_yes
248
end
249
if i==""
250
return default_yes
251
elsif i=="y"
252
return true
253
elsif i=="n"
254
return false
255
end
256
end
257
end
258
259
class GroupConfig
260
attr_reader :dirname
261
262
def initialize(dirname=".", trusted_users=nil)
263
@dirname = dirname
264
if trusted_users
265
@trusted_users_source = trusted_users
266
load_deprecated_trusted_users​()
267
elsif FileTest​.​exists​?(​CONFIG_FILE​)
268
t = {}
269
begin
270
yaml = YAML​::​load_file​(​CONFIG_FILE​)
271
yaml["trusted_users"].each do |k,v|
272
t[File.expand_path(k)] = v
273
end
274
@trusted_users_source = CONFIG_FILE
275
d = File.expand_path(dirname)
276
while d != "/"
277
if t.include?(d)
278
@trusted_users = t[d]
279
@dirname = d
280
break
281
end
282
d = File.split(d)[0]
283
end
284
if @trusted_users.nil? or d == "/"
285
raise ("Could not find #{File.expand_path(dirname)} or its parents in configuration file #{CONFIG_FILE}")
286
end
287
rescue Psych::SyntaxError, ArgumentError => e
288
raise("Could not parse YAML: #{e.message}")
289
end
290
else
291
@trusted_users_source = ENV​[​'HOME'​]+​'/.pws-trusted-users'
292
load_deprecated_trusted_users​()
293
end
294
parse_file
295
expand_groups
296
end
297
298
def load_deprecated_trusted_users​()
299
begin
300
f = File​.​open​(​@trusted_users_source​)
301
rescue Exception => e
302
raise e
303
end
304
305
trusted = []
306
f.readlines.each do |line|
307
line.chomp!
308
next if line =~ /^$/
309
next if line =~ /^#/
310
311
trusted.push line
312
end
313
@trusted_users = trusted
314
end
315
316
def verify(content)
317
args = []
318
args.push "--keyring=#{@dirname}/.keyring" if FileTest​.​exists​?(​File​.​join​(​@dirname​, ".keyring"))
319
(outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(content, args)
320
goodsig = false
321
validsig = nil
322
statustxt.split("\n").each do |line|
323
if m = /^\[GNUPG:\] GOODSIG/.match(line)
324
goodsig = true
325
elsif m = /^\[GNUPG:\] VALIDSIG \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ ([0-9A-F]+)/.match(line)
326
validsig = m[1]
327
end
328
end
329
330
if not goodsig
331
STDERR.puts ".users file is not signed properly. GnuPG said on stdout:"
332
STDERR.puts outtxt
333
STDERR.puts "and on stderr:"
334
STDERR.puts stderrtxt
335
STDERR.puts "and via statusfd:"
336
STDERR.puts statustxt
337
raise "Not goodsig"
338
end
339
340
if not @trusted_users​.​include​?(​validsig​)
341
raise ".users file is signed by #{validsig} which is not in #{@trusted_users_source}"
342
end
343
344
if not exitstatus==0
345
raise "gpg verify failed for .users file"
346
end
347
348
return outtxt
349
end
350
351
def parse_file
352
begin
353
f = File​.​open​(​File​.​join​(​@dirname​, '.users'))
354
rescue Exception => e
355
raise e
356
end
357
358
users = f.read
359
f.close
360
361
users = verify(users)
362
363
@users = {}
364
@groups = {}
365
366
lno = 0
367
users.split("\n").each do |line|
368
lno = lno+1
369
next if line =~ /^$/
370
next if line =~ /^#/
371
if (m = /^(#{USER_PATTERN})\s*=\s*([0-9A-Fa-f]{40})\s*$/​.​match line)
372
user = m[1]
373
fpr = m[2]
374
if @users.has_key?(user)
375
STDERR.puts "User #{user} redefined at line #{lno}!"
376
exit(1)
377
end
378
@users[user] = fpr
379
elsif (m = /^(#{GROUP_PATTERN})\s*=\s*(.*)$/​.​match line)
380
group = m[1]
381
members = m[2].strip
382
if @groups.has_key?(group)
383
STDERR.puts "Group #{group} redefined at line #{lno}!"
384
exit(1)
385
end
386
members = members.split(/[\t ,]+/)
387
@groups[group] = { "members" => members }
388
end
389
end
390
end
391
392
def is_group(name)
393
return (name =~ /^@/)
394
end
395
def check_exists(x, whence, fatal=true)
396
ok=true
397
if is_group(x)
398
ok=false unless (@groups.has_key?(x))
399
else
400
ok=false unless @users.has_key?(x)
401
end
402
unless ok
403
STDERR.puts( (fatal ? "Error: " : "Warning: ") + "#{whence} contains unknown member #{x}")
404
exit(1) if fatal
405
end
406
return ok
407
end
408
def expand_groups
409
@groups.each_pair do |groupname, group|
410
group['members'].each do |member|
411
check_exists(member, "Group #{groupname}")
412
end
413
group['members_to_do'] = group['members'].clone
414
end
415
416
while true
417
had_progress = false
418
all_expanded = true
419
@groups.each_pair do |groupname, group|
420
group['keys'] = [] unless group['keys']
421
422
still_contains_groups = false
423
group​[​'members_to_do'​].​clone​.​each do |member|
424
if is_group(member)
425
if @groups​[​member​][​'members_to_do'​].​size == 0
426
group['keys'].concat @groups[member]['keys']
427
group​[​'members_to_do'​].​delete​(​member​)
428
had_progress = true
429
else
430
still_contains_groups = true
431
end
432
else
433
group['keys'].push @users[member]
434
group​[​'members_to_do'​].​delete​(​member​)
435
had_progress = true
436
end
437
end
438
all_expanded = false if still_contains_groups
439
end
440
break if all_expanded
441
unless had_progress
442
cyclic_groups = @groups.keys.reject{|name| @groups​[​name​][​'members_to_do'​].​size == 0}.join(", ")
443
STDERR.puts "Cyclic group memberships in #{cyclic_groups}?"
444
exit(1)
445
end
446
end
447
end
448
449
def expand_targets(targets)
450
fprs = []
451
ok = true
452
targets.each do |t|
453
unless check_exists(t, "access line", false)
454
ok = false
455
next
456
end
457
if is_group(t)
458
fprs.concat @groups[t]['keys']
459
else
460
fprs.push @users[t]
461
end
462
end
463
return ok, fprs.uniq
464
end
465
466
def get_users()
467
return @users
468
end
469
end
470
471
class EncryptedData
472
attr_reader :accessible, :encrypted, :readable, :readers
473
474
def EncryptedData​.​determine_readable​(​readers​)
475
GnuPG.get_my_keys.each do |keyid|
476
return true if readers.include?(keyid)
477
end
478
return false
479
end
480
481
def EncryptedData​.​list_readers​(​statustxt​)
482
readers = []
483
statustxt.split("\n").each do |line|
484
m = /^\[GNUPG:\] ENC_TO ([0-9A-F]+)/.match line
485
next unless m
486
readers.push m[1]
487
end
488
return readers
489
end
490
491
def EncryptedData.targets(text)
492
text.split("\n").each do |line|
493
if /^(#|---)/.match line
494
next
495
end
496
m = /^access: "?((?:(?:#{GROUP_PATTERN}|#{USER_PATTERN}),?\s*)+)"?/​.​match line
497
return [] unless m
498
return m[1].strip.split(/[\t ,]+/)
499
end
500
end
501
502
503
def initialize(encrypted_content, label, keyring_directory = ".", ignore_decrypt_errors = false)
504
@ignore_decrypt_errors = ignore_decrypt_errors
505
@label = label
506
@keyring_dir = keyring_directory
507
508
@encrypted_content = encrypted_content
509
(outtxt, stderrtxt, statustxt) = GnuPG​.​gpgcall​(​@encrypted_content​, %w{--with-colons --no-options --no-default-keyring --secret-keyring=/dev/null --keyring=/dev/null})
510
@encrypted = !(statustxt =~ /\[GNUPG:\] NODATA/)
511
if @encrypted
512
@readers = EncryptedData​.​list_readers​(​statustxt​)
513
@readable = EncryptedData​.​determine_readable​(​@readers​)
514
end
515
end
516
517
def decrypt
518
(outtxt, stderrtxt, statustxt, exitstatus) = GnuPG​.​gpgcall​(​@encrypted_content​, %w{--decrypt})
519
if !@ignore_decrypt_errors and exitstatus != 0
520
proceed = read_input("Warning: gpg returned non-zero exit status #{exitstatus} when decrypting #{@label}. Proceed?", false)
521
exit(0) unless proceed
522
elsif !@ignore_decrypt_errors and outtxt.length == 0
523
proceed = read_input("Warning: #{@label} decrypted to an empty file. Proceed?")
524
exit(0) unless proceed
525
end
526
527
return outtxt
528
end
529
530
def encrypt(content, recipients)
531
args = recipients.collect{ |r| "--recipient=#{r}"}
532
args.push "--trust-model=always"
533
args​.​push​(​"--keyring=#{@keyring_dir}/.keyring"​, "--no-default-keyring") if FileTest​.​exists​?(​"#{@keyring_dir}/.keyring"​)
534
args.push "--armor"
535
args.push "--encrypt"
536
(outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(content, args)
537
538
invalid = []
539
statustxt.split("\n").each do |line|
540
m = /^\[GNUPG:\] INV_RECP \S+ ([0-9A-F]+)/.match line
541
next unless m
542
invalid.push m[1]
543
end
544
if invalid.size > 0
545
again = read_input("Warning: the following recipients are invalid: #{invalid.join(", ")}. Try again (or proceed)?")
546
return false if again
547
end
548
if outtxt.length == 0
549
tryagain = read_input("Error: #{@label} encrypted to an empty file. Edit again (or exit)?")
550
return false if tryagain
551
exit(0)
552
end
553
if exitstatus != 0
554
proceed = read_input("Warning: gpg returned non-zero exit status #{exitstatus} when encrypting #{@label}. Said:\n#{stderrtxt}\n#{statustxt}\n\nProceed (or try again)?")
555
return false unless proceed
556
end
557
558
return true, outtxt
559
end
560
561
562
def determine_encryption_targets​(​content​)
563
targets = EncryptedData​.​targets​(​content​)
564
if targets.size == 0
565
tryagain = read_input("Warning: Did not find targets to encrypt to in header. Try again (or exit)?", true)
566
return false if tryagain
567
exit(0)
568
end
569
570
ok, expanded = @groupconfig​.​expand_targets​(​targets​)
571
if (expanded.size == 0)
572
tryagain = read_input("Errors in access header. Edit again (or exit)?", true)
573
return false if tryagain
574
exit(0)
575
elsif (not ok)
576
tryagain = read_input("Warnings in access header. Edit again (or continue)?", true)
577
return false if tryagain
578
end
579
580
to_me = false
581
GnuPG.get_my_fprs.each do |fpr|
582
if expanded.include?(fpr)
583
to_me = true
584
break
585
end
586
end
587
unless to_me
588
tryagain = read_input("File is not being encrypted to you. Edit again (or continue)?", true)
589
return false if tryagain
590
end
591
592
return true, expanded
593
end
594
595
end
596
597
class EncryptedFile < EncryptedData
598
def initialize(filename, new=false, trusted_file=nil)
599
@groupconfig = GroupConfig​.​new​(​dirname​=​File​.​dirname​(​filename​), trusted_users=trusted_file)
600
@new = new
601
if @new
602
@readers = []
603
end
604
605
@filename = filename
606
@accessible = FileTest.readable?(filename)
607
@filename = filename
608
609
if @accessible
610
encrypted_content = File.read(filename)
611
else
612
encrypted_content = nil
613
end
614
super(encrypted_content, filename, @groupconfig.dirname, @new)
615
end
616
617
def write_back(content, targets)
618
ok, encrypted = encrypt(content, targets)
619
return false unless ok
620
621
File​.​open​(​@filename​,​"w"​).​write​(​encrypted​)
622
return true
623
end
624
end
625
626
class Ls
627
def help(parser, code=0, io=STDOUT)
628
io.puts "Usage: #{$program_name} ls [<directory> ...]"
629
io.puts parser.summarize
630
io.puts "Lists the contents of the given directory/directories, or the current"
631
io.puts "directory if none is given. For each file show whether it is PGP-encrypted"
632
io.puts "file, and if yes whether we can read it."
633
exit(code)
634
end
635
636
def ls_dir(dirname)
637
begin
638
dir = Dir.open(dirname)
639
rescue Exception => e
640
STDERR.puts e
641
return
642
end
643
puts "#{dirname}:"
644
Dir.chdir(dirname) do
645
unless FileTest.exists?(".users")
646
STDERR.puts "The .users file does not exists here. This is not a password store, is it?"
647
exit(1)
648
end
649
dir.sort.each do |filename|
650
next if (filename =~ /^\./) and not (@all >= 3)
651
stat = File::Stat.new(filename)
652
if stat.symlink?
653
puts "(sym) #{filename}" if (@all >= 2)
654
elsif stat.directory?
655
puts "(dir) #{filename}" if (@all >= 2)
656
elsif !stat.file?
657
puts "(other) #{filename}" if (@all >= 2)
658
else
659
f = EncryptedFile.new(filename)
660
if !f.accessible
661
puts "(!perm) #{filename}"
662
elsif !f.encrypted
663
puts "(file) #{filename}" if (@all >= 2)
664
elsif f.readable
665
puts "(ok) #{filename}"
666
else
667
puts "(locked) #{filename}" if (@all >= 1)
668
end
669
end
670
end
671
end
672
end
673
674
def initialize()
675
@all = 0
676
ARGV.options do |opts|
677
opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
678
opts.on_tail("-a", "--all" , "Show all files (use up to 3 times to show even more than all)") { @all = @all+1 }
679
opts.parse!
680
end
681
682
dirs = ARGV
683
dirs.push('.') unless dirs.size > 0
684
dirs.each { |dir| ls_dir(dir) }
685
end
686
end
687
688
class Ed
689
def help(parser, code=0, io=STDOUT)
690
io.puts "Usage: #{$program_name} ed <filename>"
691
io.puts parser.summarize
692
io.puts "Decrypts the file, spawns an editor, and encrypts it again"
693
exit(code)
694
end
695
696
def do_edit(content)
697
oldsize = content.length
698
699
tempfile = Tempfile.open('pws')
700
tempfile.puts content
701
tempfile.flush
702
system($editor, tempfile.path)
703
status = $?
704
throw "Process has not exited!?" unless status.exited?
705
unless status.exitstatus == 0
706
proceed = read_input("Warning: Editor did not exit successfully (exit code #{status.exitstatus}. Proceed?")
707
exit(0) unless proceed
708
end
709
710
# some editors do not write new content in place, but instead
711
# make a new file and more it in the old file's place.
712
begin
713
reopened = File.open(tempfile.path, "r+")
714
rescue Exception => e
715
STDERR.puts e
716
exit(1)
717
end
718
content = reopened.read
719
720
# zero the file, well, both of them.
721
newsize = content.length
722
clearsize = (newsize > oldsize) ? newsize : oldsize
723
724
[tempfile, reopened].each do |f|
725
f.seek(0, IO::SEEK_SET)
726
f.print "\0"*clearsize
727
f.fsync
728
end
729
reopened.close
730
tempfile.close(true)
731
732
return content
733
end
734
735
def edit(filename)
736
encrypted_file = EncryptedFile.new(filename, @new)
737
if !@new and !encrypted_file.readable && !@force
738
STDERR.puts "#{filename} is probably not readable"
739
exit(1)
740
end
741
742
encrypted_to = GnuPG​.​get_fprs_from_keyids​(​encrypted_file​.​readers​).​sort
743
744
content = encrypted_file.decrypt
745
original_content = content
746
while true
747
content = do_edit(content)
748
749
if content.length == 0
750
proceed = read_input("Warning: Content is now empty. Proceed?")
751
exit(0) unless proceed
752
end
753
754
ok, targets = encrypted_file​.​determine_encryption_targets​(​content​)
755
next unless ok
756
757
if (original_content == content && ! @reencrypt_on_change)
758
if (targets.sort == encrypted_to)
759
exit(0)
760
else
761
STDERR.puts("Notice: list of keys changed -- re-encryption recommended. Run #{$program_name} rc #{filename}")
762
exit(0)
763
end
764
end
765
766
success = encrypted_file​.​write_back​(​content​, targets)
767
break if success
768
end
769
end
770
771
def initialize​(​reencrypt_on_change​=​false​)
772
ARGV.options do |opts|
773
opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
774
opts.on_tail("-n", "--new" , "Edit new file") { |new| @new=new }
775
opts.on_tail("-f", "--force" , "Spawn an editor even if the file is probably not readable") { |force| @force=force }
776
opts.parse!
777
end
778
help(ARGV.options, 1, STDERR) if ARGV.length != 1
779
filename = ARGV.shift
780
781
if @new
782
if FileTest.exists?(filename)
783
STDERR.puts "#{filename} does exist"
784
exit(1)
785
end
786
else
787
if !FileTest.exists?(filename)
788
STDERR.puts "#{filename} does not exist"
789
exit(1)
790
elsif !FileTest.file?(filename)
791
STDERR.puts "#{filename} is not a regular file"
792
exit(1)
793
elsif !FileTest.readable?(filename)
794
STDERR.puts "#{filename} is not accessible (unix perms)"
795
exit(1)
796
end
797
end
798
799
@reencrypt_on_change = reencrypt_on_change
800
801
dirname = File.dirname(filename)
802
basename = File.basename(filename)
803
Dir.chdir(dirname) {
804
edit(basename)
805
}
806
end
807
end
808
809
class Reencrypt < Ed
810
def help(parser, code=0, io=STDOUT)
811
io.puts "Usage: #{$program_name} rc <filename>"
812
io.puts parser.summarize
813
io.puts "Reencrypts the file"
814
exit(code)
815
end
816
def do_edit(content)
817
return content
818
end
819
def initialize()
820
super(true)
821
end
822
end
823
824
class Get
825
def help(parser, code=0, io=STDOUT)
826
io.puts "Usage: #{$program_name} get <filename> <query>"
827
io.puts parser.summarize
828
io.puts "Decrypts the file, fetches a key and outputs it to stdout."
829
io.puts "The file must be in YAML format."
830
io.puts "query is a query, formatted like /host/users/root"
831
exit(code)
832
end
833
834
def get(filename, what)
835
encrypted_file = EncryptedFile.new(filename, @new)
836
if !encrypted_file.readable
837
STDERR.puts "#{filename} is probably not readable"
838
exit(1)
839
end
840
841
begin
842
yaml = YAML​::​load​(​encrypted_file​.​decrypt​)
843
rescue Psych::SyntaxError, ArgumentError => e
844
STDERR.puts "Could not parse YAML: #{e.message}"
845
exit(1)
846
end
847
848
require 'pp'
849
850
a = what.split("/")[1..-1]
851
hit = yaml
852
if a.nil?
853
# q = /, so print top level keys
854
puts "Keys:"
855
hit.keys.each do |k|
856
puts "- #{k}"
857
end
858
return
859
end
860
a.each do |k|
861
hit = hit[k]
862
end
863
if hit.nil?
864
STDERR.puts("No such key or invalid lookup expression")
865
elsif hit.respond_to?(:keys)
866
puts "Keys:"
867
hit.keys.each do |k|
868
puts "- #{k}"
869
end
870
else
871
puts hit
872
end
873
end
874
875
def initialize()
876
ARGV.options do |opts|
877
opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
878
opts.parse!
879
end
880
help(ARGV.options, 1, STDERR) if ARGV.length != 2
881
filename = ARGV.shift
882
what = ARGV.shift
883
884
if !FileTest.exists?(filename)
885
STDERR.puts "#{filename} does not exist"
886
exit(1)
887
elsif !FileTest.file?(filename)
888
STDERR.puts "#{filename} is not a regular file"
889
exit(1)
890
elsif !FileTest.readable?(filename)
891
STDERR.puts "#{filename} is not accessible (unix perms)"
892
exit(1)
893
end
894
895
dirname = File.dirname(filename)
896
basename = File.basename(filename)
897
Dir.chdir(dirname) {
898
get(basename, what)
899
}
900
end
901
end
902
903
class KeyringUpdater
904
def help(parser, code=0, io=STDOUT)
Moritz Mühlenhofff52ec442020-11-16 13:29:43 +0100[diff] [blame]
905
io.puts "Usage: #{$program_name} update-keyring"
Moritz Mühlenhoff14759462020-04-07 12:02:30 +0200[diff] [blame]
906
io.puts parser.summarize
907
io.puts "Updates the local .keyring file"
908
exit(code)
909
end
910
911
def initialize()
912
ARGV.options do |opts|
913
opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
914
opts.parse!
915
end
916
help(ARGV.options, 1, STDERR) if ARGV.length > 1
Moritz Mühlenhoff14759462020-04-07 12:02:30 +0200[diff] [blame]
917
918
groupconfig = GroupConfig.new
919
users = groupconfig.get_users()
Moritz Mühlenhoff14759462020-04-07 12:02:30 +0200[diff] [blame]
920
Moritz Mühlenhofff52ec442020-11-16 13:29:43 +0100[diff] [blame]
921
File.open('.keyring', 'w') do |keyring|
922
users.each_pair() do |uid, keyid|
923
user_keyfile = File.join('keys', "#{uid}.key")
924
if File.file?(user_keyfile)
925
keyring​.​write​(​File​.​read​(​user_keyfile​))
Moritz Mühlenhoffde36acf2020-11-17 10:12:23 +0100[diff] [blame]
926
else
927
puts "No key file found for user " + uid
Moritz Mühlenhofff52ec442020-11-16 13:29:43 +0100[diff] [blame]
928
end
Moritz Mühlenhoff14759462020-04-07 12:02:30 +0200[diff] [blame]
929
end
Moritz Mühlenhoff14759462020-04-07 12:02:30 +0200[diff] [blame]
930
end
931
Moritz Mühlenhoff14759462020-04-07 12:02:30 +0200[diff] [blame]
932
end
933
end
934
935
class GitDiff
936
def help(parser, code=0, io=STDOUT)
937
io.puts "Usage: #{$program_name} gitdiff <commit> <file>"
938
io.puts parser.summarize
939
io.puts "Shows a diff between the version of <file> in your directory and the"
940
io.puts "version in git at <commit> (or HEAD). Requires that your tree be git"
941
io.puts "managed, obviously."
942
exit(code)
943
end
944
945
def check_readable(e, label)
946
if !e.readable && !@force
947
STDERR.puts "#{label} is probably not readable."
948
exit(1)
949
end
950
end
951
952
def get_file_at_commit()
953
label = @commit+':'+@filename
954
(encrypted_content, stderrtxt, exitcode) = GnuPG.open3call('git', '', ['show', label], require_success=true, do_status=false)
955
data = EncryptedData​.​new​(​encrypted_content​, label)
956
check_readable(data, label)
957
return data.decrypt
958
end
959
960
def get_file_current()
961
data = EncryptedFile​.​new​(​@filename​)
962
check_readable(data, @filename)
963
return data.decrypt
964
end
965
966
def diff()
967
old = get_file_at_commit()
968
cur = get_file_current()
969
970
t1 = Tempfile.open('pws')
971
t1.puts old
972
t1.flush
973
974
t2 = Tempfile.open('pws')
975
t2.puts cur
976
t2.flush
977
978
system("diff", "-u", t1.path, t2.path)
979
980
t1.seek(0, IO::SEEK_SET)
981
t1.print "\0"*old.length
982
t1.fsync
983
t1.close(true)
984
985
t2.seek(0, IO::SEEK_SET)
986
t2.print "\0"*cur.length
987
t2.fsync
988
t2.close(true)
989
end
990
991
def initialize()
992
ARGV.options do |opts|
993
opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
994
opts.on_tail("-f", "--force" , "Do it even if the file is probably not readable") { |force| @force=force }
995
opts.parse!
996
end
997
998
if ARGV.length == 1
999
@commit = 'HEAD'
1000
@filename = ARGV.shift
1001
elsif ARGV.length == 2
1002
@commit = ARGV.shift
1003
@filename = ARGV.shift
1004
else
1005
help(ARGV.options, 1, STDERR)
1006
end
1007
1008
diff()
1009
end
1010
end
1011
1012
1013
def help(code=0, io=STDOUT)
1014
io.puts "Usage: #{$program_name} ed"
1015
io.puts " #{$program_name} rc"
1016
io.puts " #{$program_name} ls"
1017
io.puts " #{$program_name} gitdiff"
1018
io.puts " #{$program_name} update-keyring"
1019
io.puts " #{$program_name} help"
1020
io.puts "Call #{$program_name} <command> --help for additional options/parameters"
1021
exit(code)
1022
end
1023
1024
1025
def parse_command
1026
case ARGV.shift
1027
when 'ls' then Ls.new
1028
when 'ed' then Ed.new
1029
when 'rc' then Reencrypt.new
1030
when 'gitdiff' then GitDiff.new
1031
when 'get' then Get.new
1032
when 'update-keyring' then KeyringUpdater.new
1033
when 'help' then
1034
case ARGV.length
1035
when 0 then help
1036
when 1 then
1037
ARGV.push "--help"
1038
parse_command
1039
else help(1, STDERR)
1040
end
1041
else
1042
help(1, STDERR)
1043
end
1044
end
1045
1046
if __FILE__ == $0
1047
begin
1048
parse_command
1049
rescue RuntimeError => e
1050
STDERR.puts e.message
1051
exit(1)
1052
end
1053
end
1054
1055
# vim:set shiftwidth=2:
1056
# vim:set et:
1057
# vim:set ts=2:
Powered by Gitiles
txt
json