]> err.no Git - pwstore/blob - pws
67591b3d5b0be1bb812ccd45b1a2189f201adc8d
[pwstore] / pws
1 #!/usr/bin/ruby
2
3 # password store management tool
4
5 # Copyright (c) 2008 Peter Palfrader <peter@palfrader.org>
6 #
7 # Permission is hereby granted, free of charge, to any person obtaining
8 # a copy of this software and associated documentation files (the
9 # "Software"), to deal in the Software without restriction, including
10 # without limitation the rights to use, copy, modify, merge, publish,
11 # distribute, sublicense, and/or sell copies of the Software, and to
12 # permit persons to whom the Software is furnished to do so, subject to
13 # the following conditions:
14 #
15 # The above copyright notice and this permission notice shall be
16 # included in all copies or substantial portions of the Software.
17 #
18 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
19 # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
20 # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
21 # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
22 # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
23 # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
24 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
25
26 require 'optparse'
27 require 'thread'
28 require 'tempfile'
29
30 require 'yaml'
31 Thread.abort_on_exception = true
32
33 GNUPG = "/usr/bin/gpg"
34
35 $program_name = File.basename($0, '.*')
36
37 $editor = ENV['EDITOR']
38 if $editor == nil
39   %w{/usr/bin/sensible-editor /usr/bin/editor /usr/bin/vi}.each do |editor|
40     if FileTest.executable?(editor)
41       $editor = editor
42       break
43     end
44   end
45 end
46
47 class GnuPG
48   @@my_keys = nil
49   @@my_fprs = nil
50   @@keyid_fpr_mapping = {}
51
52   def GnuPG.readwrite3(intxt, infd, stdoutfd, stderrfd, statusfd)
53     outtxt, stderrtxt, statustxt = ''
54     thread_in = Thread.new {
55       infd.print intxt
56       infd.close
57     }
58     thread_out = Thread.new {
59       outtxt = stdoutfd.read
60       stdoutfd.close
61     }
62     thread_err = Thread.new {
63       errtxt = stderrfd.read
64       stderrfd.close
65     }
66     thread_status = Thread.new {
67       statustxt = statusfd.read
68       statusfd.close
69     } if (statusfd)
70
71     thread_in.join
72     thread_out.join
73     thread_err.join
74     thread_status.join if thread_status
75
76     return outtxt, stderrtxt, statustxt
77   end
78
79   def GnuPG.gpgcall(intxt, args, require_success = false)
80     inR, inW = IO.pipe
81     outR, outW = IO.pipe
82     errR, errW = IO.pipe
83     statR, statW = IO.pipe
84
85     pid = Kernel.fork do
86       inW.close
87       outR.close
88       errR.close
89       statR.close
90       STDIN.reopen(inR)
91       STDOUT.reopen(outW)
92       STDERR.reopen(errW)
93       exec(GNUPG, "--status-fd=#{statW.fileno}",  *args)
94       raise ("Calling gnupg failed")
95     end
96     inR.close
97     outW.close
98     errW.close
99     statW.close
100     (outtxt, stderrtxt, statustxt) = readwrite3(intxt, inW, outR, errR, statR);
101     wpid, status = Process.waitpid2 pid
102     throw "Unexpected pid: #{pid} vs #{wpid}" unless pid == wpid
103     throw "Process has not exited!?" unless status.exited?
104     throw "gpg call did not exit sucessfully" if (require_success and status.exitstatus != 0)
105     return outtxt, stderrtxt, statustxt, status.exitstatus
106   end
107
108   def GnuPG.init_keys()
109     return if @@my_keys
110     (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', %w{--fast-list-mode --with-colons --with-fingerprint --list-secret-keys}, true)
111     @@my_keys = []
112     @@my_fprs = []
113     outtxt.split("\n").each do |line|
114       parts = line.split(':')
115       if (parts[0] == "ssb" or parts[0] == "sec")
116         @@my_keys.push parts[4]
117       elsif (parts[0] == "fpr")
118         @@my_fprs.push parts[9]
119       end
120     end
121   end
122   # This is for my private keys, so we can tell if a file is encrypted to us
123   def GnuPG.get_my_keys()
124     init_keys
125     @@my_keys
126   end
127   # And this is for my private keys also, so we can tell if we are encrypting to ourselves
128   def GnuPG.get_my_fprs()
129     init_keys
130     @@my_fprs
131   end
132
133   # This maps public keyids to fingerprints, so we can figure
134   # out if a file that is encrypted to a bunch of keys is
135   # encrypted to the fingerprints it should be encrypted to
136   def GnuPG.get_fpr_from_keyid(keyid)
137     fpr = @@keyid_fpr_mapping[keyid]
138     # this can be null, if we tried to find the fpr but failed to find the key in our keyring
139     unless fpr
140       STDERR.puts "Warning: No key found for keyid #{keyid}"
141     end
142     return fpr
143   end
144   def GnuPG.get_fprs_from_keyids(keyids)
145     learn_fingerprints_from_keyids(keyids)
146     return keyids.collect{ |k| get_fpr_from_keyid(k) }
147   end
148
149   # this is to load the keys we will soon be asking about into
150   # our keyid-fpr-mapping hash
151   def GnuPG.learn_fingerprints_from_keyids(keyids)
152     need_to_learn = keyids.reject{ |k| @@keyid_fpr_mapping.has_key?(k) }
153     if need_to_learn.size > 0
154       args = %w{--fast-list-mode --with-colons --with-fingerprint --list-keys}
155       args.concat need_to_learn
156       (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', args, true)
157
158       pub = nil
159       fpr = nil
160       outtxt.split("\n").each do |line|
161         parts = line.split(':')
162         if (parts[0] == "pub")
163           pub = parts[4]
164         elsif (parts[0] == "fpr")
165           fpr = parts[9]
166           @@keyid_fpr_mapping[pub] = fpr
167         elsif (parts[0] == "sub")
168           @@keyid_fpr_mapping[parts[4]] = fpr
169         end
170       end
171     end
172     need_to_learn.reject{ |k| @@keyid_fpr_mapping.has_key?(k) }.each { |k| @@keyid_fpr_mapping[k] = nil }
173   end
174 end
175
176 def read_input(query, default_yes=true)
177   if default_yes
178     append = '[Y/n]'
179   else
180     append = '[y/N]'
181   end
182
183   while true
184     print "#{query} #{append} "
185     i = STDIN.readline.chomp.downcase
186     if i==""
187       return default_yes
188     elsif i=="y"
189       return true
190     elsif i=="n"
191       return false
192     end
193   end
194 end
195
196 class GroupConfig
197   def initialize
198     parse_file
199     expand_groups
200   end
201
202   def verify(content)
203     begin
204       f = File.open(ENV['HOME']+'/.pws-trusted-users')
205     rescue Exception => e
206       STDERR.puts e
207       exit(1)
208     end
209
210     trusted = []
211     f.readlines.each do |line|
212       line.chomp!
213       next if line =~ /^$/
214       next if line =~ /^#/
215
216       trusted.push line
217     end
218
219     (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(content, %w{}, true)
220     goodsig = false
221     validsig = nil
222     statustxt.split("\n").each do |line|
223       if m = /^\[GNUPG:\] GOODSIG/.match(line)
224         goodsig = true
225       elsif m = /^\[GNUPG:\] VALIDSIG \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ ([0-9A-F]+)/.match(line)
226         validsig = m[1]
227       end
228     end
229
230     if not goodsig
231       STDERR.puts ".users file is not signed properly"
232       exit(1)
233     end
234
235     if not trusted.include?(validsig)
236       STDERR.puts ".users file is signed by #{validsig} which is not in ~/.pws-trusted-users"
237       exit(1)
238     end
239
240     return outtxt
241   end
242
243   def parse_file
244     begin
245       f = File.open('.users')
246     rescue Exception => e
247       STDERR.puts e
248       exit(1)
249     end
250
251     users = f.read
252     f.close
253
254     users = verify(users)
255
256     @users = {}
257     @groups = {}
258
259     lno = 0
260     users.split("\n").each do |line|
261       lno = lno+1
262       next if line =~ /^$/
263       next if line =~ /^#/
264       if (m = /^([a-zA-Z0-9-]+)\s*=\s*([0-9A-Fa-f]{40})\s*$/.match line)
265         user = m[1]
266         fpr = m[2]
267         if @users.has_key?(user)
268           STDERR.puts "User #{user} redefined at line #{lno}!"
269           exit(1)
270         end
271         @users[user] = fpr
272       elsif (m = /^(@[a-zA-Z0-9-]+)\s*=\s*(.*)$/.match line)
273         group = m[1]
274         members = m[2].strip
275         if @groups.has_key?(group)
276           STDERR.puts "Group #{group} redefined at line #{lno}!"
277           exit(1)
278         end
279         members = members.split(/[\t ,]+/)
280         @groups[group] = { "members" => members }
281       end
282     end
283   end
284
285   def is_group(name)
286     return (name =~ /^@/)
287   end
288   def check_exists(x, whence, fatal=true)
289     ok=true
290     if is_group(x)
291       ok=false unless (@groups.has_key?(x))
292     else
293       ok=false unless @users.has_key?(x)
294     end
295     unless ok
296       STDERR.puts( (fatal ? "Error: " : "Warning: ") + "#{whence} contains unknown member #{x}")
297       exit(1) if fatal
298     end
299     return ok
300   end
301   def expand_groups
302     @groups.each_pair do |groupname, group|
303       group['members'].each do |member|
304         check_exists(member, "Group #{groupname}")
305       end
306       group['members_to_do'] = group['members'].clone
307     end
308
309     while true
310       had_progress = false
311       all_expanded = true
312       @groups.each_pair do |groupname, group|
313         group['keys'] = [] unless group['keys'] 
314
315         still_contains_groups = false
316         group['members_to_do'].each do |member|
317           if is_group(member)
318             if @groups[member]['members_to_do'].size == 0
319               group['keys'].concat @groups[member]['keys']
320               group['members_to_do'].delete(member)
321               had_progress = true
322             else
323               still_contains_groups = true
324             end
325           else
326             group['keys'].push @users[member]
327             group['members_to_do'].delete(member)
328             had_progress = true
329           end
330         end
331         all_expanded = false if still_contains_groups
332       end
333       break if all_expanded
334       unless had_progress
335         cyclic_groups = @groups.keys.reject{|name| @groups[name]['members_to_do'].size == 0}.join(", ")
336         STDERR.puts "Cyclic group memberships in #{cyclic_groups}?"
337         exit(1)
338       end
339     end
340   end
341
342   def expand_targets(targets)
343     fprs = []
344     ok = true
345     targets.each do |t|
346       unless check_exists(t, "access line", false)
347         ok = false
348         next
349       end
350       if is_group(t)
351         fprs.concat @groups[t]['keys']
352       else
353         fprs.push @users[t]
354       end
355     end
356     return ok, fprs.uniq
357   end
358 end
359
360 class EncryptedFile
361   attr_reader :accessible, :encrypted, :readable, :readers
362
363   def EncryptedFile.determine_readable(readers)
364     GnuPG.get_my_keys.each do |keyid|
365       return true if readers.include?(keyid)
366     end
367     return false
368   end
369
370   def EncryptedFile.list_readers(statustxt)
371     readers = []
372     statustxt.split("\n").each do |line|
373       m = /^\[GNUPG:\] ENC_TO ([0-9A-F]+)/.match line
374       next unless m
375       readers.push m[1]
376     end
377     return readers
378   end
379
380   def EncryptedFile.targets(text)
381     metaline = text.split("\n").first
382     m = /^access: (.*)/.match metaline
383     return [] unless m
384     return m[1].strip.split(/[\t ,]+/)
385   end
386
387
388   def initialize(filename, new=false)
389     @groupconfig = GroupConfig.new
390     @new = new
391     if @new
392       @readers = []
393     end
394
395     @filename = filename
396     unless FileTest.readable?(filename)
397       @accessible = false
398       return
399     end
400     @accessible = true
401     @encrypted_content = File.read(filename)
402     (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall(@encrypted_content, %w{--with-colons --no-default-keyring --secret-keyring=/dev/null --keyring=/dev/null})
403     @encrypted = !(statustxt =~ /\[GNUPG:\] NODATA/)
404     if @encrypted
405       @readers = EncryptedFile.list_readers(statustxt)
406       @readable = EncryptedFile.determine_readable(@readers)
407     end
408   end
409
410   def decrypt
411     (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(@encrypted_content, %w{--decrypt})
412     if !@new and exitstatus != 0
413       proceed = read_input("Warning: gpg returned non-zero exit status #{exitstatus} when decrypting #{@filename}.  Proceed?", false)
414       exit(0) unless proceed
415     elsif !@new and outtxt.length == 0
416       proceed = read_input("Warning: #{@filename} decrypted to an empty file.  Proceed?")
417       exit(0) unless proceed
418     end
419
420     return outtxt
421   end
422
423   def encrypt(content, recipients)
424     args = recipients.collect{ |r| "--recipient=#{r}"}
425     args.push "--trust-model=always"
426     args.push "--encrypt"
427     (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(content, args)
428
429     invalid = []
430     statustxt.split("\n").each do |line|
431       m = /^\[GNUPG:\] INV_RECP \S+ ([0-9A-F]+)/.match line
432       next unless m
433       invalid.push m[1]
434     end
435     if invalid.size > 0
436       again = read_input("Warning: the following recipients are invalid: #{invalid.join(", ")}. Try again (or proceed)?")
437       return false if again
438     end
439     if outtxt.length == 0
440       tryagain = read_input("Error: #{@filename} encrypted to an empty file.  Edit again (or exit)?")
441       return false if tryagain
442       exit(0)
443     end
444     if exitstatus != 0
445       proceed = read_input("Warning: gpg returned non-zero exit status #{exitstatus} when encrypting #{@filename}. Said:\n#{stderrtxt}\n#{statustxt}\n\nProceed (or try again)?")
446       return false unless proceed
447     end
448
449     return true, outtxt
450   end
451
452
453   def determine_encryption_targets(content)
454     targets = EncryptedFile.targets(content)
455     if targets.size == 0
456       tryagain = read_input("Warning: Did not find targets to encrypt to in header.  Try again (or exit)?", true)
457       return false if tryagain
458       exit(0)
459     end
460
461     ok, expanded = @groupconfig.expand_targets(targets)
462     if (expanded.size == 0)
463       tryagain = read_input("Errors in access header.  Edit again (or exit)?", true)
464       return false if tryagain
465       exit(0)
466     elsif (not ok)
467       tryagain = read_input("Warnings in access header.  Edit again (or continue)?", true)
468       return false if tryagain
469     end
470
471     to_me = false
472     GnuPG.get_my_fprs.each do |fpr|
473       if expanded.include?(fpr)
474         to_me = true
475         break
476       end
477     end
478     unless to_me
479       tryagain = read_input("File is not being encrypted to you.  Edit again (or continue)?", true)
480       return false if tryagain
481     end
482
483     return true, expanded
484   end
485
486   def write_back(content, targets)
487     ok, encrypted = encrypt(content, targets)
488     return false unless ok
489
490     File.open(@filename,"w").write(encrypted)
491     return true
492   end
493 end
494
495 class Ls
496   def help(parser, code=0, io=STDOUT)
497     io.puts "Usage: #{$program_name} ls [<directory> ...]"
498     io.puts parser.summarize
499     io.puts "Lists the contents of the given directory/directories, or the current"
500     io.puts "directory if none is given.  For each file show whether it is PGP-encrypted"
501     io.puts "file, and if yes whether we can read it."
502     exit(code)
503   end
504
505   def ls_dir(dirname)
506     begin
507       dir = Dir.open(dirname)
508     rescue Exception => e
509       STDERR.puts e
510       return
511     end
512     puts "#{dirname}:"
513     Dir.chdir(dirname) do
514       unless FileTest.exists?(".users")
515         STDERR.puts "The .users file does not exists here.  This is not a password store, is it?"
516         exit(1)
517       end
518       dir.sort.each do |filename|
519         next if (filename =~ /^\./) and not (@all >= 3)
520         stat = File::Stat.new(filename)
521         if stat.symlink?
522           puts "(sym)      #{filename}" if (@all >= 2)
523         elsif stat.directory?
524           puts "(dir)      #{filename}" if (@all >= 2)
525         elsif !stat.file?
526           puts "(other)    #{filename}" if (@all >= 2)
527         else
528           f = EncryptedFile.new(filename)
529           if !f.accessible
530             puts "(!perm)    #{filename}"
531           elsif !f.encrypted
532             puts "(file)     #{filename}" if (@all >= 2)
533           elsif f.readable
534             puts "(ok)       #{filename}"
535           else
536             puts "(locked)   #{filename}" if (@all >= 1)
537           end
538         end
539       end
540     end
541   end
542
543   def initialize()
544     @all = 0
545     ARGV.options do |opts|
546       opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
547       opts.on_tail("-a", "--all" , "Show all files (use up to 3 times to show even more than all)") { @all = @all+1 }
548       opts.parse!
549     end
550
551     dirs = ARGV
552     dirs.push('.') unless dirs.size > 0
553     dirs.each { |dir| ls_dir(dir) }
554   end
555 end
556
557 class Ed
558   def help(parser, code=0, io=STDOUT)
559     io.puts "Usage: #{$program_name} ed <filename>"
560     io.puts parser.summarize
561     io.puts "Decrypts the file, spawns an editor, and encrypts it again"
562     exit(code)
563   end
564
565   def edit(filename)
566     encrypted_file = EncryptedFile.new(filename, @new)
567     if !@new and !encrypted_file.readable && !@force
568       STDERR.puts "#{filename} is probably not readable"
569       exit(1)
570     end
571
572     encrypted_to = GnuPG.get_fprs_from_keyids(encrypted_file.readers).sort
573
574     content = encrypted_file.decrypt
575     original_content = content
576     while true
577       oldsize = content.length
578       tempfile = Tempfile.open('pws')
579       tempfile.puts content
580       tempfile.flush
581       system($editor, tempfile.path)
582       status = $?
583       throw "Process has not exited!?" unless status.exited?
584       unless status.exitstatus == 0
585         proceed = read_input("Warning: Editor did not exit successfully (exit code #{status.exitstatus}.  Proceed?")
586         exit(0) unless proceed
587       end
588       tempfile.seek(0, IO::SEEK_SET)
589       content = tempfile.read
590
591       # zero the file
592       newsize = content.length
593       tempfile.seek(0, IO::SEEK_SET)
594       clearsize = (newsize > oldsize) ? newsize : oldsize
595       tempfile.print "\0"*clearsize
596       tempfile.fsync
597       tempfile.close(true)
598
599       if content.length == 0
600         proceed = read_input("Warning: Content is now empty.  Proceed?")
601         exit(0) unless proceed
602       end
603
604       ok, targets = encrypted_file.determine_encryption_targets(content)
605       next unless ok
606
607       if (original_content == content)
608         if (targets.sort == encrypted_to)
609           proceed = read_input("Nothing changed.  Re-encrypt anyway?", false)
610           exit(0) unless proceed
611         else
612           STDERR.puts("Info: Content not changed but re-encrypting anyway because the list of keys changed")
613         end
614       end
615
616       success = encrypted_file.write_back(content, targets)
617       break if success
618     end
619   end
620
621   def initialize()
622     ARGV.options do |opts|
623       opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
624       opts.on_tail("-n", "--new" , "Edit new file") { |@new| }
625       opts.on_tail("-f", "--force" , "Spawn an editor even if the file is probably not readable") { |@force| }
626       opts.parse!
627     end
628     help(ARGV.options, 1, STDERR) if ARGV.length != 1
629     filename = ARGV.shift
630
631     if @new
632       if FileTest.exists?(filename)
633         STDERR.puts "#{filename} does exist"
634         exit(1)
635       end
636     else
637       if !FileTest.exists?(filename)
638         STDERR.puts "#{filename} does not exist"
639         exit(1)
640       elsif !FileTest.file?(filename)
641         STDERR.puts "#{filename} is not a regular file"
642         exit(1)
643       elsif !FileTest.readable?(filename)
644         STDERR.puts "#{filename} is not accessible (unix perms)"
645         exit(1)
646       end
647     end
648
649     dirname = File.dirname(filename)
650     basename = File.basename(filename)
651     Dir.chdir(dirname) {
652       edit(basename)
653     }
654   end
655 end
656
657
658 def help(code=0, io=STDOUT)
659   io.puts "Usage: #{$program_name} ed"
660   io.puts "       #{$program_name} ls"
661   io.puts "       #{$program_name} help"
662   io.puts "Call #{$program_name} <command> --help for additional options/parameters"
663   exit(code)
664 end
665
666
667 def parse_command
668   case ARGV.shift
669     when 'ls': Ls.new
670     when 'ed': Ed.new
671     when 'help': 
672       case ARGV.length
673         when 0: help
674         when 1:
675           ARGV.push "--help"
676           parse_command
677         else help(1, STDERR)
678       end
679     else
680       help(1, STDERR)
681   end
682 end
683
684 parse_command
685
686 # vim:set shiftwidth=2:
687 # vim:set et:
688 # vim:set ts=2: