]> err.no Git - pwstore/blob - pws
gnupg is broken. news @11
[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) or "unknown" }
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       # we can't use --fast-list-mode here because GnuPG is broken
155       # and does not show elmo's fingerprint in a call like
156       # gpg --with-colons --fast-list-mode --with-fingerprint --list-key D7C3F131AB2A91F5
157       args = %w{--with-colons --with-fingerprint --list-keys}
158       args.concat need_to_learn
159       (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', args, true)
160
161       pub = nil
162       fpr = nil
163       outtxt.split("\n").each do |line|
164         parts = line.split(':')
165         if (parts[0] == "pub")
166           pub = parts[4]
167         elsif (parts[0] == "fpr")
168           fpr = parts[9]
169           @@keyid_fpr_mapping[pub] = fpr
170         elsif (parts[0] == "sub")
171           @@keyid_fpr_mapping[parts[4]] = fpr
172         end
173       end
174     end
175     need_to_learn.reject{ |k| @@keyid_fpr_mapping.has_key?(k) }.each { |k| @@keyid_fpr_mapping[k] = nil }
176   end
177 end
178
179 def read_input(query, default_yes=true)
180   if default_yes
181     append = '[Y/n]'
182   else
183     append = '[y/N]'
184   end
185
186   while true
187     print "#{query} #{append} "
188     i = STDIN.readline.chomp.downcase
189     if i==""
190       return default_yes
191     elsif i=="y"
192       return true
193     elsif i=="n"
194       return false
195     end
196   end
197 end
198
199 class GroupConfig
200   def initialize
201     parse_file
202     expand_groups
203   end
204
205   def verify(content)
206     begin
207       f = File.open(ENV['HOME']+'/.pws-trusted-users')
208     rescue Exception => e
209       STDERR.puts e
210       exit(1)
211     end
212
213     trusted = []
214     f.readlines.each do |line|
215       line.chomp!
216       next if line =~ /^$/
217       next if line =~ /^#/
218
219       trusted.push line
220     end
221
222     (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(content, %w{}, true)
223     goodsig = false
224     validsig = nil
225     statustxt.split("\n").each do |line|
226       if m = /^\[GNUPG:\] GOODSIG/.match(line)
227         goodsig = true
228       elsif m = /^\[GNUPG:\] VALIDSIG \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ ([0-9A-F]+)/.match(line)
229         validsig = m[1]
230       end
231     end
232
233     if not goodsig
234       STDERR.puts ".users file is not signed properly"
235       exit(1)
236     end
237
238     if not trusted.include?(validsig)
239       STDERR.puts ".users file is signed by #{validsig} which is not in ~/.pws-trusted-users"
240       exit(1)
241     end
242
243     return outtxt
244   end
245
246   def parse_file
247     begin
248       f = File.open('.users')
249     rescue Exception => e
250       STDERR.puts e
251       exit(1)
252     end
253
254     users = f.read
255     f.close
256
257     users = verify(users)
258
259     @users = {}
260     @groups = {}
261
262     lno = 0
263     users.split("\n").each do |line|
264       lno = lno+1
265       next if line =~ /^$/
266       next if line =~ /^#/
267       if (m = /^([a-zA-Z0-9-]+)\s*=\s*([0-9A-Fa-f]{40})\s*$/.match line)
268         user = m[1]
269         fpr = m[2]
270         if @users.has_key?(user)
271           STDERR.puts "User #{user} redefined at line #{lno}!"
272           exit(1)
273         end
274         @users[user] = fpr
275       elsif (m = /^(@[a-zA-Z0-9-]+)\s*=\s*(.*)$/.match line)
276         group = m[1]
277         members = m[2].strip
278         if @groups.has_key?(group)
279           STDERR.puts "Group #{group} redefined at line #{lno}!"
280           exit(1)
281         end
282         members = members.split(/[\t ,]+/)
283         @groups[group] = { "members" => members }
284       end
285     end
286   end
287
288   def is_group(name)
289     return (name =~ /^@/)
290   end
291   def check_exists(x, whence, fatal=true)
292     ok=true
293     if is_group(x)
294       ok=false unless (@groups.has_key?(x))
295     else
296       ok=false unless @users.has_key?(x)
297     end
298     unless ok
299       STDERR.puts( (fatal ? "Error: " : "Warning: ") + "#{whence} contains unknown member #{x}")
300       exit(1) if fatal
301     end
302     return ok
303   end
304   def expand_groups
305     @groups.each_pair do |groupname, group|
306       group['members'].each do |member|
307         check_exists(member, "Group #{groupname}")
308       end
309       group['members_to_do'] = group['members'].clone
310     end
311
312     while true
313       had_progress = false
314       all_expanded = true
315       @groups.each_pair do |groupname, group|
316         group['keys'] = [] unless group['keys'] 
317
318         still_contains_groups = false
319         group['members_to_do'].each do |member|
320           if is_group(member)
321             if @groups[member]['members_to_do'].size == 0
322               group['keys'].concat @groups[member]['keys']
323               group['members_to_do'].delete(member)
324               had_progress = true
325             else
326               still_contains_groups = true
327             end
328           else
329             group['keys'].push @users[member]
330             group['members_to_do'].delete(member)
331             had_progress = true
332           end
333         end
334         all_expanded = false if still_contains_groups
335       end
336       break if all_expanded
337       unless had_progress
338         cyclic_groups = @groups.keys.reject{|name| @groups[name]['members_to_do'].size == 0}.join(", ")
339         STDERR.puts "Cyclic group memberships in #{cyclic_groups}?"
340         exit(1)
341       end
342     end
343   end
344
345   def expand_targets(targets)
346     fprs = []
347     ok = true
348     targets.each do |t|
349       unless check_exists(t, "access line", false)
350         ok = false
351         next
352       end
353       if is_group(t)
354         fprs.concat @groups[t]['keys']
355       else
356         fprs.push @users[t]
357       end
358     end
359     return ok, fprs.uniq
360   end
361 end
362
363 class EncryptedFile
364   attr_reader :accessible, :encrypted, :readable, :readers
365
366   def EncryptedFile.determine_readable(readers)
367     GnuPG.get_my_keys.each do |keyid|
368       return true if readers.include?(keyid)
369     end
370     return false
371   end
372
373   def EncryptedFile.list_readers(statustxt)
374     readers = []
375     statustxt.split("\n").each do |line|
376       m = /^\[GNUPG:\] ENC_TO ([0-9A-F]+)/.match line
377       next unless m
378       readers.push m[1]
379     end
380     return readers
381   end
382
383   def EncryptedFile.targets(text)
384     metaline = text.split("\n").first
385     m = /^access: (.*)/.match metaline
386     return [] unless m
387     return m[1].strip.split(/[\t ,]+/)
388   end
389
390
391   def initialize(filename, new=false)
392     @groupconfig = GroupConfig.new
393     @new = new
394     if @new
395       @readers = []
396     end
397
398     @filename = filename
399     unless FileTest.readable?(filename)
400       @accessible = false
401       return
402     end
403     @accessible = true
404     @encrypted_content = File.read(filename)
405     (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall(@encrypted_content, %w{--with-colons --no-default-keyring --secret-keyring=/dev/null --keyring=/dev/null})
406     @encrypted = !(statustxt =~ /\[GNUPG:\] NODATA/)
407     if @encrypted
408       @readers = EncryptedFile.list_readers(statustxt)
409       @readable = EncryptedFile.determine_readable(@readers)
410     end
411   end
412
413   def decrypt
414     (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(@encrypted_content, %w{--decrypt})
415     if !@new and exitstatus != 0
416       proceed = read_input("Warning: gpg returned non-zero exit status #{exitstatus} when decrypting #{@filename}.  Proceed?", false)
417       exit(0) unless proceed
418     elsif !@new and outtxt.length == 0
419       proceed = read_input("Warning: #{@filename} decrypted to an empty file.  Proceed?")
420       exit(0) unless proceed
421     end
422
423     return outtxt
424   end
425
426   def encrypt(content, recipients)
427     args = recipients.collect{ |r| "--recipient=#{r}"}
428     args.push "--trust-model=always"
429     args.push "--encrypt"
430     (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(content, args)
431
432     invalid = []
433     statustxt.split("\n").each do |line|
434       m = /^\[GNUPG:\] INV_RECP \S+ ([0-9A-F]+)/.match line
435       next unless m
436       invalid.push m[1]
437     end
438     if invalid.size > 0
439       again = read_input("Warning: the following recipients are invalid: #{invalid.join(", ")}. Try again (or proceed)?")
440       return false if again
441     end
442     if outtxt.length == 0
443       tryagain = read_input("Error: #{@filename} encrypted to an empty file.  Edit again (or exit)?")
444       return false if tryagain
445       exit(0)
446     end
447     if exitstatus != 0
448       proceed = read_input("Warning: gpg returned non-zero exit status #{exitstatus} when encrypting #{@filename}. Said:\n#{stderrtxt}\n#{statustxt}\n\nProceed (or try again)?")
449       return false unless proceed
450     end
451
452     return true, outtxt
453   end
454
455
456   def determine_encryption_targets(content)
457     targets = EncryptedFile.targets(content)
458     if targets.size == 0
459       tryagain = read_input("Warning: Did not find targets to encrypt to in header.  Try again (or exit)?", true)
460       return false if tryagain
461       exit(0)
462     end
463
464     ok, expanded = @groupconfig.expand_targets(targets)
465     if (expanded.size == 0)
466       tryagain = read_input("Errors in access header.  Edit again (or exit)?", true)
467       return false if tryagain
468       exit(0)
469     elsif (not ok)
470       tryagain = read_input("Warnings in access header.  Edit again (or continue)?", true)
471       return false if tryagain
472     end
473
474     to_me = false
475     GnuPG.get_my_fprs.each do |fpr|
476       if expanded.include?(fpr)
477         to_me = true
478         break
479       end
480     end
481     unless to_me
482       tryagain = read_input("File is not being encrypted to you.  Edit again (or continue)?", true)
483       return false if tryagain
484     end
485
486     return true, expanded
487   end
488
489   def write_back(content, targets)
490     ok, encrypted = encrypt(content, targets)
491     return false unless ok
492
493     File.open(@filename,"w").write(encrypted)
494     return true
495   end
496 end
497
498 class Ls
499   def help(parser, code=0, io=STDOUT)
500     io.puts "Usage: #{$program_name} ls [<directory> ...]"
501     io.puts parser.summarize
502     io.puts "Lists the contents of the given directory/directories, or the current"
503     io.puts "directory if none is given.  For each file show whether it is PGP-encrypted"
504     io.puts "file, and if yes whether we can read it."
505     exit(code)
506   end
507
508   def ls_dir(dirname)
509     begin
510       dir = Dir.open(dirname)
511     rescue Exception => e
512       STDERR.puts e
513       return
514     end
515     puts "#{dirname}:"
516     Dir.chdir(dirname) do
517       unless FileTest.exists?(".users")
518         STDERR.puts "The .users file does not exists here.  This is not a password store, is it?"
519         exit(1)
520       end
521       dir.sort.each do |filename|
522         next if (filename =~ /^\./) and not (@all >= 3)
523         stat = File::Stat.new(filename)
524         if stat.symlink?
525           puts "(sym)      #{filename}" if (@all >= 2)
526         elsif stat.directory?
527           puts "(dir)      #{filename}" if (@all >= 2)
528         elsif !stat.file?
529           puts "(other)    #{filename}" if (@all >= 2)
530         else
531           f = EncryptedFile.new(filename)
532           if !f.accessible
533             puts "(!perm)    #{filename}"
534           elsif !f.encrypted
535             puts "(file)     #{filename}" if (@all >= 2)
536           elsif f.readable
537             puts "(ok)       #{filename}"
538           else
539             puts "(locked)   #{filename}" if (@all >= 1)
540           end
541         end
542       end
543     end
544   end
545
546   def initialize()
547     @all = 0
548     ARGV.options do |opts|
549       opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
550       opts.on_tail("-a", "--all" , "Show all files (use up to 3 times to show even more than all)") { @all = @all+1 }
551       opts.parse!
552     end
553
554     dirs = ARGV
555     dirs.push('.') unless dirs.size > 0
556     dirs.each { |dir| ls_dir(dir) }
557   end
558 end
559
560 class Ed
561   def help(parser, code=0, io=STDOUT)
562     io.puts "Usage: #{$program_name} ed <filename>"
563     io.puts parser.summarize
564     io.puts "Decrypts the file, spawns an editor, and encrypts it again"
565     exit(code)
566   end
567
568   def edit(filename)
569     encrypted_file = EncryptedFile.new(filename, @new)
570     if !@new and !encrypted_file.readable && !@force
571       STDERR.puts "#{filename} is probably not readable"
572       exit(1)
573     end
574
575     encrypted_to = GnuPG.get_fprs_from_keyids(encrypted_file.readers).sort
576
577     content = encrypted_file.decrypt
578     original_content = content
579     while true
580       oldsize = content.length
581       tempfile = Tempfile.open('pws')
582       tempfile.puts content
583       tempfile.flush
584       system($editor, tempfile.path)
585       status = $?
586       throw "Process has not exited!?" unless status.exited?
587       unless status.exitstatus == 0
588         proceed = read_input("Warning: Editor did not exit successfully (exit code #{status.exitstatus}.  Proceed?")
589         exit(0) unless proceed
590       end
591       tempfile.seek(0, IO::SEEK_SET)
592       content = tempfile.read
593
594       # zero the file
595       newsize = content.length
596       tempfile.seek(0, IO::SEEK_SET)
597       clearsize = (newsize > oldsize) ? newsize : oldsize
598       tempfile.print "\0"*clearsize
599       tempfile.fsync
600       tempfile.close(true)
601
602       if content.length == 0
603         proceed = read_input("Warning: Content is now empty.  Proceed?")
604         exit(0) unless proceed
605       end
606
607       ok, targets = encrypted_file.determine_encryption_targets(content)
608       next unless ok
609
610       if (original_content == content)
611         if (targets.sort == encrypted_to)
612           proceed = read_input("Nothing changed.  Re-encrypt anyway?", false)
613           exit(0) unless proceed
614         else
615           STDERR.puts("Info: Content not changed but re-encrypting anyway because the list of keys changed")
616         end
617       end
618
619       success = encrypted_file.write_back(content, targets)
620       break if success
621     end
622   end
623
624   def initialize()
625     ARGV.options do |opts|
626       opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
627       opts.on_tail("-n", "--new" , "Edit new file") { |@new| }
628       opts.on_tail("-f", "--force" , "Spawn an editor even if the file is probably not readable") { |@force| }
629       opts.parse!
630     end
631     help(ARGV.options, 1, STDERR) if ARGV.length != 1
632     filename = ARGV.shift
633
634     if @new
635       if FileTest.exists?(filename)
636         STDERR.puts "#{filename} does exist"
637         exit(1)
638       end
639     else
640       if !FileTest.exists?(filename)
641         STDERR.puts "#{filename} does not exist"
642         exit(1)
643       elsif !FileTest.file?(filename)
644         STDERR.puts "#{filename} is not a regular file"
645         exit(1)
646       elsif !FileTest.readable?(filename)
647         STDERR.puts "#{filename} is not accessible (unix perms)"
648         exit(1)
649       end
650     end
651
652     dirname = File.dirname(filename)
653     basename = File.basename(filename)
654     Dir.chdir(dirname) {
655       edit(basename)
656     }
657   end
658 end
659
660
661 def help(code=0, io=STDOUT)
662   io.puts "Usage: #{$program_name} ed"
663   io.puts "       #{$program_name} ls"
664   io.puts "       #{$program_name} help"
665   io.puts "Call #{$program_name} <command> --help for additional options/parameters"
666   exit(code)
667 end
668
669
670 def parse_command
671   case ARGV.shift
672     when 'ls': Ls.new
673     when 'ed': Ed.new
674     when 'help': 
675       case ARGV.length
676         when 0: help
677         when 1:
678           ARGV.push "--help"
679           parse_command
680         else help(1, STDERR)
681       end
682     else
683       help(1, STDERR)
684   end
685 end
686
687 parse_command
688
689 # vim:set shiftwidth=2:
690 # vim:set et:
691 # vim:set ts=2: