]> err.no Git - pwstore/blob - pws
7cbc8f4605896dd0e6387067032b2295605671ad
[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
50   def GnuPG.readwrite3(intxt, infd, stdoutfd, stderrfd, statusfd)
51     outtxt, stderrtxt, statustxt = ''
52     thread_in = Thread.new {
53       infd.print intxt
54       infd.close
55     }
56     thread_out = Thread.new {
57       outtxt = stdoutfd.read
58       stdoutfd.close
59     }
60     thread_err = Thread.new {
61       errtxt = stderrfd.read
62       stderrfd.close
63     }
64     thread_status = Thread.new {
65       statustxt = statusfd.read
66       statusfd.close
67     } if (statusfd)
68
69     thread_in.join
70     thread_out.join
71     thread_err.join
72     thread_status.join if thread_status
73
74     return outtxt, stderrtxt, statustxt
75   end
76
77   def GnuPG.gpgcall(intxt, args, require_success = false)
78     inR, inW = IO.pipe
79     outR, outW = IO.pipe
80     errR, errW = IO.pipe
81     statR, statW = IO.pipe
82
83     pid = Kernel.fork do
84       inW.close
85       outR.close
86       errR.close
87       statR.close
88       STDIN.reopen(inR)
89       STDOUT.reopen(outW)
90       STDERR.reopen(errW)
91       exec(GNUPG, "--status-fd=#{statW.fileno}",  *args)
92       raise ("Calling gnupg failed")
93     end
94     inR.close
95     outW.close
96     errW.close
97     statW.close
98     (outtxt, stderrtxt, statustxt) = readwrite3(intxt, inW, outR, errR, statR);
99     wpid, status = Process.waitpid2 pid
100     throw "Unexpected pid: #{pid} vs #{wpid}" unless pid == wpid
101     throw "Process has not exited!?" unless status.exited?
102     throw "gpg call did not exit sucessfully" if (require_success and status.exitstatus != 0)
103     return outtxt, stderrtxt, statustxt, status.exitstatus
104   end
105
106   def GnuPG.init_keys()
107     return if @@my_keys
108     (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', %w{--fast-list-mode --with-colons --with-fingerprint --list-secret-keys}, true)
109     @@my_keys = []
110     @@my_fprs = []
111     outtxt.split("\n").each do |line|
112       parts = line.split(':')
113       if (parts[0] == "ssb" or parts[0] == "sec")
114         @@my_keys.push parts[4]
115       elsif (parts[0] == "fpr")
116         @@my_fprs.push parts[9]
117       end
118     end
119   end
120   def GnuPG.get_my_keys()
121     init_keys
122     @@my_keys
123   end
124   def GnuPG.get_my_fprs()
125     init_keys
126     @@my_fprs
127   end
128 end
129
130 def read_input(query, default_yes=true)
131   if default_yes
132     append = '[Y/n]'
133   else
134     append = '[y/N]'
135   end
136
137   while true
138     print "#{query} #{append} "
139     i = STDIN.readline.chomp.downcase
140     if i==""
141       return default_yes
142     elsif i=="y"
143       return true
144     elsif i=="n"
145       return false
146     end
147   end
148 end
149
150 class GroupConfig
151   def initialize
152     parse_file
153     expand_groups
154   end
155
156   def parse_file
157     begin
158       f = File.open('.users')
159     rescue Exception => e
160       STDERR.puts e
161       exit(1)
162     end
163
164     @users = {}
165     @groups = {}
166
167     lno = 0
168     f.readlines.each do |line|
169       lno = lno+1
170       next if line =~ /^$/
171       next if line =~ /^#/
172       if (m = /^([a-zA-Z0-9-]+)\s*=\s*([0-9A-Fa-f]{40})\s*$/.match line)
173         user = m[1]
174         fpr = m[2]
175         if @users.has_key?(user)
176           STDERR.puts "User #{user} redefined at line #{lno}!"
177           exit(1)
178         end
179         @users[user] = fpr
180       elsif (m = /^(@[a-zA-Z0-9-]+)\s*=\s*(.*)$/.match line)
181         group = m[1]
182         members = m[2].strip
183         if @groups.has_key?(group)
184           STDERR.puts "Group #{group} redefined at line #{lno}!"
185           exit(1)
186         end
187         members = members.split(/[\t ,]+/)
188         @groups[group] = { "members" => members }
189       end
190     end
191   end
192
193   def is_group(name)
194     return (name =~ /^@/)
195   end
196   def check_exists(x, whence, fatal=true)
197     ok=true
198     if is_group(x)
199       ok=false unless (@groups.has_key?(x))
200     else
201       ok=false unless @users.has_key?(x)
202     end
203     unless ok
204       STDERR.puts( (fatal ? "Error: " : "Warning: ") + "#{whence} contains unknown member #{x}")
205       exit(1) if fatal
206     end
207     return ok
208   end
209   def expand_groups
210     @groups.each_pair do |groupname, group|
211       group['members'].each do |member|
212         check_exists(member, "Group #{groupname}")
213       end
214       group['members_to_do'] = group['members'].clone
215     end
216
217     while true
218       had_progress = false
219       all_expanded = true
220       @groups.each_pair do |groupname, group|
221         group['keys'] = [] unless group['keys'] 
222
223         still_contains_groups = false
224         group['members_to_do'].each do |member|
225           if is_group(member)
226             if @groups[member]['members_to_do'].size == 0
227               group['keys'].concat @groups[member]['keys']
228               group['members_to_do'].delete(member)
229               had_progress = true
230             else
231               still_contains_groups = true
232             end
233           else
234             group['keys'].push @users[member]
235             group['members_to_do'].delete(member)
236             had_progress = true
237           end
238         end
239         all_expanded = false if still_contains_groups
240       end
241       break if all_expanded
242       unless had_progress
243         cyclic_groups = @groups.keys.reject{|name| @groups[name]['members_to_do'].size == 0}.join(", ")
244         STDERR.puts "Cyclic group memberships in #{cyclic_groups}?"
245         exit(1)
246       end
247     end
248   end
249
250   def expand_targets(targets)
251     fprs = []
252     ok = true
253     targets.each do |t|
254       unless check_exists(t, "access line", false)
255         ok = false
256         next
257       end
258       if is_group(t)
259         fprs.concat @groups[t]['keys']
260       else
261         fprs.push @users[t]
262       end
263     end
264     return ok, fprs
265   end
266 end
267
268 class EncryptedFile
269   attr_reader :accessible, :encrypted, :readable
270
271   def EncryptedFile.determine_readable(readers)
272     GnuPG.get_my_keys.each do |keyid|
273       return true if readers.include?(keyid)
274     end
275     return false
276   end
277
278   def EncryptedFile.list_readers(statustxt)
279     readers = []
280     statustxt.split("\n").each do |line|
281       m = /^\[GNUPG:\] ENC_TO ([0-9A-F]+)/.match line
282       next unless m
283       readers.push m[1]
284     end
285     return readers
286   end
287
288   def EncryptedFile.targets(text)
289     metaline = text.split("\n").first
290     m = /^access: (.*)/.match metaline
291     return [] unless m
292     return m[1].strip.split(/[\t ,]+/)
293   end
294
295
296   def initialize(filename, new=false)
297     @groupconfig = GroupConfig.new
298     @new = new
299
300     @filename = filename
301     unless FileTest.readable?(filename)
302       @accessible = false
303       return
304     end
305     @accessible = true
306     @encrypted_content = File.read(filename)
307     (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall(@encrypted_content, %w{--with-colons --no-default-keyring --secret-keyring=/dev/null --keyring=/dev/null})
308     @encrypted = !(statustxt =~ /\[GNUPG:\] NODATA/)
309     if @encrypted
310       @readers = EncryptedFile.list_readers(statustxt)
311       @readable = EncryptedFile.determine_readable(@readers)
312     end
313   end
314
315   def decrypt
316     (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(@encrypted_content, %w{--decrypt})
317     if !@new and exitstatus != 0
318       proceed = read_input("Warning: gpg returned non-zero exit status #{exitstatus} when decrypting #{@filename}.  Proceed?")
319       exit(0) unless proceed
320     elsif !@new and outtxt.length == 0
321       proceed = read_input("Warning: #{@filename} decrypted to an empty file.  Proceed?")
322       exit(0) unless proceed
323     end
324
325     return outtxt
326   end
327
328   def encrypt(content, recipients)
329     args = recipients.collect{ |r| "--recipient=#{r}"}
330     args.push "--trust-model=always"
331     args.push "--encrypt"
332     (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(content, args)
333
334     if exitstatus != 0
335       proceed = read_input("Warning: gpg returned non-zero exit status #{exitstatus} when encrypting #{@filename}.  Proceed (or try again)?")
336       return false unless proceed
337     elsif outtxt.length == 0
338       tryagain = read_input("Error: #{@filename} decrypted to an empty file.  Edit again (or exit)?")
339       return false if tryagain
340       exit(0)
341     end
342
343     return true, outtxt
344   end
345
346
347   def write_back(content)
348     targets = EncryptedFile.targets(content)
349     if targets.size == 0
350       tryagain = read_input("Warning: Did not find targets to encrypt to in header.  Try again (or exit)?", true)
351       return false if tryagain
352       exit(0)
353     end
354
355     ok, expanded = @groupconfig.expand_targets(targets)
356     if (expanded.size == 0)
357       tryagain = read_input("Errors in access header.  Edit again (or exit)?", true)
358       return false if tryagain
359       exit(0)
360     elsif (not ok)
361       tryagain = read_input("Warnings in access header.  Edit again (or continue)?", true)
362       return false if tryagain
363     end
364
365     to_me = false
366     GnuPG.get_my_fprs.each do |fpr|
367       if expanded.include?(fpr)
368         to_me = true
369         break
370       end
371     end
372     unless to_me
373       tryagain = read_input("File is not being encrypted to you.  Edit again (or continue)?", true)
374       return false if tryagain
375     end
376
377     ok, encrypted = encrypt(content, expanded)
378     return false unless ok
379
380     File.open(@filename,"w").write(encrypted)
381     return true
382   end
383 end
384
385 class Ls
386   def help(parser, code=0, io=STDOUT)
387     io.puts "Usage: #{$program_name} ls [<directory> ...]"
388     io.puts parser.summarize
389     io.puts "Lists the contents of the given directory/directories, or the current"
390     io.puts "directory if none is given.  For each file show whether it is PGP-encrypted"
391     io.puts "file, and if yes whether we can read it."
392     exit(code)
393   end
394
395   def ls_dir(dirname)
396     begin
397       dir = Dir.open(dirname)
398     rescue Exception => e
399       STDERR.puts e
400       return
401     end
402     puts "#{dirname}:"
403     Dir.chdir(dirname) do
404       dir.sort.each do |filename|
405         next if (filename =~ /^\./) and not (@all >= 3)
406         stat = File::Stat.new(filename)
407         if stat.symlink?
408           puts "(sym)      #{filename}" if (@all >= 2)
409         elsif stat.directory?
410           puts "(dir)      #{filename}" if (@all >= 2)
411         elsif !stat.file?
412           puts "(other)    #{filename}" if (@all >= 2)
413         else
414           f = EncryptedFile.new(filename)
415           if !f.accessible
416             puts "(!perm)    #{filename}"
417           elsif !f.encrypted
418             puts "(file)     #{filename}" if (@all >= 2)
419           elsif f.readable
420             puts "(ok)       #{filename}"
421           else
422             puts "(locked)   #{filename}" if (@all >= 1)
423           end
424         end
425       end
426     end
427   end
428
429   def initialize()
430     @all = 0
431     ARGV.options do |opts|
432       opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
433       opts.on_tail("-a", "--all" , "Show all files (use up to 3 times to show even more than all)") { @all = @all+1 }
434       opts.parse!
435     end
436
437     dirs = ARGV
438     dirs.push('.') unless dirs.size > 0
439     dirs.each { |dir| ls_dir(dir) }
440   end
441 end
442
443 class Ed
444   def help(parser, code=0, io=STDOUT)
445     io.puts "Usage: #{$program_name} ed <filename>"
446     io.puts parser.summarize
447     io.puts "Decrypts the file, spawns an editor, and encrypts it again"
448     exit(code)
449   end
450
451   def edit(filename)
452     encrypted_file = EncryptedFile.new(filename, @new)
453     if !@new and !encrypted_file.readable && !@force
454       STDERR.puts "#{filename} is probably not readable"
455       exit(1)
456     end
457
458     content = encrypted_file.decrypt
459     while true
460       oldsize = content.length
461       tempfile = Tempfile.open('pws')
462       tempfile.puts content
463       tempfile.flush
464       system($editor, tempfile.path)
465       status = $?
466       throw "Process has not exited!?" unless status.exited?
467       unless status.exitstatus == 0
468         proceed = read_input("Warning: Editor did not exit successfully (exit code #{status.exitstatus}.  Proceed?")
469         exit(0) unless proceed
470       end
471       tempfile.seek(0, IO::SEEK_SET)
472       content = tempfile.read
473
474       # zero the file
475       newsize = content.length
476       tempfile.seek(0, IO::SEEK_SET)
477       clearsize = (newsize > oldsize) ? newsize : oldsize
478       tempfile.print "\0"*clearsize
479       tempfile.fsync
480       tempfile.close(true)
481
482       if content.length == 0
483         proceed = read_input("Warning: Content is now empty.  Proceed?")
484         exit(0) unless proceed
485       end
486
487       success = encrypted_file.write_back(content)
488       break if success
489     end
490   end
491
492   def initialize()
493     ARGV.options do |opts|
494       opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
495       opts.on_tail("-n", "--new" , "Edit new file") { |@new| }
496       opts.on_tail("-f", "--force" , "Spawn an editor even if the file is probably not readable") { |@force| }
497       opts.parse!
498     end
499     help(ARGV.options, 1, STDERR) if ARGV.length != 1
500     filename = ARGV.shift
501
502     if @new
503       if FileTest.exists?(filename)
504         STDERR.puts "#{filename} does exist"
505         exit(1)
506       end
507     else
508       if !FileTest.exists?(filename)
509         STDERR.puts "#{filename} does not exist"
510         exit(1)
511       elsif !FileTest.file?(filename)
512         STDERR.puts "#{filename} is not a regular file"
513         exit(1)
514       elsif !FileTest.readable?(filename)
515         STDERR.puts "#{filename} is not accessible (unix perms)"
516         exit(1)
517       end
518     end
519
520     dirname = File.dirname(filename)
521     basename = File.basename(filename)
522     Dir.chdir(dirname) {
523       edit(basename)
524     }
525   end
526 end
527
528
529 def help(code=0, io=STDOUT)
530   io.puts "Usage: #{$program_name} ed"
531   io.puts "       #{$program_name} ls"
532   io.puts "       #{$program_name} help"
533   io.puts "Call #{$program_name} <command> --help for additional options/parameters"
534   exit(code)
535 end
536
537
538 def parse_command
539   case ARGV.shift
540     when 'ls': Ls.new
541     when 'ed': Ed.new
542     when 'help': 
543       case ARGV.length
544         when 0: help
545         when 1:
546           ARGV.push "--help"
547           parse_command
548         else help(1, STDERR)
549       end
550     else
551       help(1, STDERR)
552   end
553 end
554
555 parse_command
556
557 # vim:set shiftwidth=2:
558 # vim:set et:
559 # vim:set ts=2: