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