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