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