blob: 3c6b9f3aff50c80fab8b847bb370a4df9c66c65d [file] [log] [blame]
Moritz Mühlenhoff14759462020-04-07 12:02:30 +02001#!/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
27require 'optparse'
28require 'thread'
29require 'tempfile'
30
31require 'yaml'
32Thread.abort_on_exception = true
33
34GNUPG = "gpg"
35GROUP_PATTERN = "@[a-zA-Z0-9_-]+"
36USER_PATTERN = "[a-zA-Z0-9:_-]+"
37$program_name = File.basename($0, '.*')
38CONFIG_FILE = ENV['HOME']+ "/.pws.yaml"
39
40$editor = ENV['EDITOR']
41if $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
48end
49if $editor == nil
50 STDERR.puts "Cannot find an editor"
51 exit(1)
52end
53
54class 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
233end
234
235def 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
257end
258
259class 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
469end
470
471class 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
595end
596
597class 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
624end
625
626class 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
686end
687
688class 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
807end
808
809class 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
822end
823
824class 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
901end
902
903class KeyringUpdater
904 def help(parser, code=0, io=STDOUT)
905 io.puts "Usage: #{$program_name} update-keyring [<keyserver>]"
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
917 keyserver = ARGV.shift
918 keyserver = 'keys.gnupg.net' unless keyserver
919
920 groupconfig = GroupConfig.new
921 users = groupconfig.get_users()
922 args = %w{--with-colons --no-options --no-default-keyring --keyring=./.keyring}
923
924 system('touch', '.keyring')
925 users.each_pair() do |uid, keyid|
926 cmd = args.clone()
927 cmd << "--keyserver=#{keyserver}"
928 cmd << "--recv-keys"
929 cmd << keyid
930 puts "Fetching key for #{uid}"
931 (outtxt, stderrtxt, statustxt, ecode) = GnuPG.gpgcall('', cmd)
932 unless (statustxt =~ /^\[GNUPG:\] IMPORT_OK /)
933 STDERR.puts "Warning: did not find IMPORT_OK token in status output"
934 STDERR.puts "gpg exited with exit code #{ecode})"
935 STDERR.puts "Command was gpg #{cmd.join(' ')}"
936 STDERR.puts "stdout was #{outtxt}"
937 STDERR.puts "stderr was #{stderrtxt}"
938 STDERR.puts "statustxt was #{statustxt}"
939 end
940
941 cmd = args.clone()
942 cmd << '--batch' << '--edit' << keyid << 'minimize' << 'save'
943 (outtxt, stderrtxt, statustxt, ecode) = GnuPG.gpgcall('', cmd)
944 end
945
946
947 end
948end
949
950class GitDiff
951 def help(parser, code=0, io=STDOUT)
952 io.puts "Usage: #{$program_name} gitdiff <commit> <file>"
953 io.puts parser.summarize
954 io.puts "Shows a diff between the version of <file> in your directory and the"
955 io.puts "version in git at <commit> (or HEAD). Requires that your tree be git"
956 io.puts "managed, obviously."
957 exit(code)
958 end
959
960 def check_readable(e, label)
961 if !e.readable && !@force
962 STDERR.puts "#{label} is probably not readable."
963 exit(1)
964 end
965 end
966
967 def get_file_at_commit()
968 label = @commit+':'+@filename
969 (encrypted_content, stderrtxt, exitcode) = GnuPG.open3call('git', '', ['show', label], require_success=true, do_status=false)
970 data = EncryptedData.new(encrypted_content, label)
971 check_readable(data, label)
972 return data.decrypt
973 end
974
975 def get_file_current()
976 data = EncryptedFile.new(@filename)
977 check_readable(data, @filename)
978 return data.decrypt
979 end
980
981 def diff()
982 old = get_file_at_commit()
983 cur = get_file_current()
984
985 t1 = Tempfile.open('pws')
986 t1.puts old
987 t1.flush
988
989 t2 = Tempfile.open('pws')
990 t2.puts cur
991 t2.flush
992
993 system("diff", "-u", t1.path, t2.path)
994
995 t1.seek(0, IO::SEEK_SET)
996 t1.print "\0"*old.length
997 t1.fsync
998 t1.close(true)
999
1000 t2.seek(0, IO::SEEK_SET)
1001 t2.print "\0"*cur.length
1002 t2.fsync
1003 t2.close(true)
1004 end
1005
1006 def initialize()
1007 ARGV.options do |opts|
1008 opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
1009 opts.on_tail("-f", "--force" , "Do it even if the file is probably not readable") { |force| @force=force }
1010 opts.parse!
1011 end
1012
1013 if ARGV.length == 1
1014 @commit = 'HEAD'
1015 @filename = ARGV.shift
1016 elsif ARGV.length == 2
1017 @commit = ARGV.shift
1018 @filename = ARGV.shift
1019 else
1020 help(ARGV.options, 1, STDERR)
1021 end
1022
1023 diff()
1024 end
1025end
1026
1027
1028def help(code=0, io=STDOUT)
1029 io.puts "Usage: #{$program_name} ed"
1030 io.puts " #{$program_name} rc"
1031 io.puts " #{$program_name} ls"
1032 io.puts " #{$program_name} gitdiff"
1033 io.puts " #{$program_name} update-keyring"
1034 io.puts " #{$program_name} help"
1035 io.puts "Call #{$program_name} <command> --help for additional options/parameters"
1036 exit(code)
1037end
1038
1039
1040def parse_command
1041 case ARGV.shift
1042 when 'ls' then Ls.new
1043 when 'ed' then Ed.new
1044 when 'rc' then Reencrypt.new
1045 when 'gitdiff' then GitDiff.new
1046 when 'get' then Get.new
1047 when 'update-keyring' then KeyringUpdater.new
1048 when 'help' then
1049 case ARGV.length
1050 when 0 then help
1051 when 1 then
1052 ARGV.push "--help"
1053 parse_command
1054 else help(1, STDERR)
1055 end
1056 else
1057 help(1, STDERR)
1058 end
1059end
1060
1061if __FILE__ == $0
1062 begin
1063 parse_command
1064 rescue RuntimeError => e
1065 STDERR.puts e.message
1066 exit(1)
1067 end
1068end
1069
1070# vim:set shiftwidth=2:
1071# vim:set et:
1072# vim:set ts=2: