]> err.no Git - pwstore/blobdiff - pws
Use ./.keyring when learning key fingerprints too
[pwstore] / pws
diff --git a/pws b/pws
index a2c8c406351c10e581389158212dffc5f6b18edf..98e475eb5eb97b93b3843f90b72800f5b8f9218c 100755 (executable)
--- a/pws
+++ b/pws
@@ -2,7 +2,7 @@
 
 # password store management tool
 
-# Copyright (c) 2008 Peter Palfrader <peter@palfrader.org>
+# Copyright (c) 2008, 2009 Peter Palfrader <peter@palfrader.org>
 #
 # Permission is hereby granted, free of charge, to any person obtaining
 # a copy of this software and associated documentation files (the
@@ -31,7 +31,8 @@ require 'yaml'
 Thread.abort_on_exception = true
 
 GNUPG = "/usr/bin/gpg"
-
+GROUP_PATTERN = "@[a-zA-Z0-9-]+"
+USER_PATTERN = "[a-zA-Z0-9:-]+"
 $program_name = File.basename($0, '.*')
 
 $editor = ENV['EDITOR']
@@ -46,6 +47,8 @@ end
 
 class GnuPG
   @@my_keys = nil
+  @@my_fprs = nil
+  @@keyid_fpr_mapping = {}
 
   def GnuPG.readwrite3(intxt, infd, stdoutfd, stderrfd, statusfd)
     outtxt, stderrtxt, statustxt = ''
@@ -88,7 +91,12 @@ class GnuPG
       STDIN.reopen(inR)
       STDOUT.reopen(outW)
       STDERR.reopen(errW)
-      exec(GNUPG, "--status-fd=#{statW.fileno}",  *args)
+      begin
+        exec(GNUPG, "--status-fd=#{statW.fileno}",  *args)
+      rescue Exception => e
+        outW.puts("[PWSEXECERROR]: #{e}")
+        exit(1)
+      end
       raise ("Calling gnupg failed")
     end
     inR.close
@@ -100,6 +108,10 @@ class GnuPG
     throw "Unexpected pid: #{pid} vs #{wpid}" unless pid == wpid
     throw "Process has not exited!?" unless status.exited?
     throw "gpg call did not exit sucessfully" if (require_success and status.exitstatus != 0)
+    if m=/^\[PWSEXECERROR\]: (.*)/.match(outtxt) then
+      STDERR.puts "Could not run GnuPG: #{m[1]}"
+      exit(1)
+    end
     return outtxt, stderrtxt, statustxt, status.exitstatus
   end
 
@@ -117,14 +129,62 @@ class GnuPG
       end
     end
   end
+  # This is for my private keys, so we can tell if a file is encrypted to us
   def GnuPG.get_my_keys()
     init_keys
     @@my_keys
   end
+  # And this is for my private keys also, so we can tell if we are encrypting to ourselves
   def GnuPG.get_my_fprs()
     init_keys
     @@my_fprs
   end
+
+  # This maps public keyids to fingerprints, so we can figure
+  # out if a file that is encrypted to a bunch of keys is
+  # encrypted to the fingerprints it should be encrypted to
+  def GnuPG.get_fpr_from_keyid(keyid)
+    fpr = @@keyid_fpr_mapping[keyid]
+    # this can be null, if we tried to find the fpr but failed to find the key in our keyring
+    unless fpr
+      STDERR.puts "Warning: No key found for keyid #{keyid}"
+    end
+    return fpr
+  end
+  def GnuPG.get_fprs_from_keyids(keyids)
+    learn_fingerprints_from_keyids(keyids)
+    return keyids.collect{ |k| get_fpr_from_keyid(k) or "unknown" }
+  end
+
+  # this is to load the keys we will soon be asking about into
+  # our keyid-fpr-mapping hash
+  def GnuPG.learn_fingerprints_from_keyids(keyids)
+    need_to_learn = keyids.reject{ |k| @@keyid_fpr_mapping.has_key?(k) }
+    if need_to_learn.size > 0
+      # we can't use --fast-list-mode here because GnuPG is broken
+      # and does not show elmo's fingerprint in a call like
+      # gpg --with-colons --fast-list-mode --with-fingerprint --list-key D7C3F131AB2A91F5
+      args = %w{--with-colons --with-fingerprint --list-keys}
+      args.push "--keyring=./.keyring" if FileTest.exists?(".keyring")
+      args.concat need_to_learn
+      (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', args, true)
+
+      pub = nil
+      fpr = nil
+      outtxt.split("\n").each do |line|
+        parts = line.split(':')
+        if (parts[0] == "pub")
+          pub = parts[4]
+        elsif (parts[0] == "fpr")
+          fpr = parts[9]
+          @@keyid_fpr_mapping[pub] = fpr
+        elsif (parts[0] == "sub")
+          @@keyid_fpr_mapping[parts[4]] = fpr
+        end
+      end
+    end
+    need_to_learn.reject{ |k| @@keyid_fpr_mapping.has_key?(k) }.each { |k| @@keyid_fpr_mapping[k] = nil }
+  end
 end
 
 def read_input(query, default_yes=true)
@@ -136,7 +196,11 @@ def read_input(query, default_yes=true)
 
   while true
     print "#{query} #{append} "
-    i = STDIN.readline.chomp.downcase
+    begin
+      i = STDIN.readline.chomp.downcase
+    rescue EOFError
+      return default_yes
+    end
     if i==""
       return default_yes
     elsif i=="y"
@@ -153,6 +217,59 @@ class GroupConfig
     expand_groups
   end
 
+  def verify(content)
+    begin
+      f = File.open(ENV['HOME']+'/.pws-trusted-users')
+    rescue Exception => e
+      STDERR.puts e
+      exit(1)
+    end
+
+    trusted = []
+    f.readlines.each do |line|
+      line.chomp!
+      next if line =~ /^$/
+      next if line =~ /^#/
+
+      trusted.push line
+    end
+
+    args = []
+    args.push "--keyring=./.keyring" if FileTest.exists?(".keyring")
+    (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(content, args)
+    goodsig = false
+    validsig = nil
+    statustxt.split("\n").each do |line|
+      if m = /^\[GNUPG:\] GOODSIG/.match(line)
+        goodsig = true
+      elsif m = /^\[GNUPG:\] VALIDSIG \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ ([0-9A-F]+)/.match(line)
+        validsig = m[1]
+      end
+    end
+
+    if not goodsig
+      STDERR.puts ".users file is not signed properly.  GnuPG said on stdout:"
+      STDERR.puts outtxt
+      STDERR.puts "and on stderr:"
+      STDERR.puts stderrtxt
+      STDERR.puts "and via statusfd:"
+      STDERR.puts statustxt
+      exit(1)
+    end
+
+    if not trusted.include?(validsig)
+      STDERR.puts ".users file is signed by #{validsig} which is not in ~/.pws-trusted-users"
+      exit(1)
+    end
+
+    if not exitstatus==0
+      STDERR.puts "gpg verify failed for .users file"
+      exit(1)
+    end
+
+    return outtxt
+  end
+
   def parse_file
     begin
       f = File.open('.users')
@@ -161,15 +278,20 @@ class GroupConfig
       exit(1)
     end
 
+    users = f.read
+    f.close
+
+    users = verify(users)
+
     @users = {}
     @groups = {}
 
     lno = 0
-    f.readlines.each do |line|
+    users.split("\n").each do |line|
       lno = lno+1
       next if line =~ /^$/
       next if line =~ /^#/
-      if (m = /^([a-zA-Z0-9-]+)\s*=\s*([0-9A-Fa-f]{40})\s*$/.match line)
+      if (m = /^(#{USER_PATTERN})\s*=\s*([0-9A-Fa-f]{40})\s*$/.match line)
         user = m[1]
         fpr = m[2]
         if @users.has_key?(user)
@@ -177,7 +299,7 @@ class GroupConfig
           exit(1)
         end
         @users[user] = fpr
-      elsif (m = /^(@[a-zA-Z0-9-]+)\s*=\s*(.*)$/.match line)
+      elsif (m = /^(#{GROUP_PATTERN})\s*=\s*(.*)$/.match line)
         group = m[1]
         members = m[2].strip
         if @groups.has_key?(group)
@@ -221,7 +343,7 @@ class GroupConfig
         group['keys'] = [] unless group['keys'] 
 
         still_contains_groups = false
-        group['members_to_do'].each do |member|
+        group['members_to_do'].clone.each do |member|
           if is_group(member)
             if @groups[member]['members_to_do'].size == 0
               group['keys'].concat @groups[member]['keys']
@@ -261,12 +383,16 @@ class GroupConfig
         fprs.push @users[t]
       end
     end
-    return ok, fprs
+    return ok, fprs.uniq
+  end
+
+  def get_users()
+    return @users
   end
 end
 
 class EncryptedFile
-  attr_reader :accessible, :encrypted, :readable
+  attr_reader :accessible, :encrypted, :readable, :readers
 
   def EncryptedFile.determine_readable(readers)
     GnuPG.get_my_keys.each do |keyid|
@@ -286,16 +412,23 @@ class EncryptedFile
   end
 
   def EncryptedFile.targets(text)
-    metaline = text.split("\n").first
-    m = /^access: (.*)/.match metaline
-    return [] unless m
-    return m[1].strip.split(/[\t ,]+/)
+    text.split("\n").each do |line|
+      if /^#/.match line
+        next
+      end
+      m = /^access: "?((?:(?:#{GROUP_PATTERN}|#{USER_PATTERN}),?\s*)+)"?/.match line
+      return [] unless m
+      return m[1].strip.split(/[\t ,]+/)
+    end
   end
 
 
   def initialize(filename, new=false)
     @groupconfig = GroupConfig.new
     @new = new
+    if @new
+      @readers = []
+    end
 
     @filename = filename
     unless FileTest.readable?(filename)
@@ -304,7 +437,7 @@ class EncryptedFile
     end
     @accessible = true
     @encrypted_content = File.read(filename)
-    (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall(@encrypted_content, %w{--with-colons --no-default-keyring --secret-keyring=/dev/null --keyring=/dev/null})
+    (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall(@encrypted_content, %w{--with-colons --no-options --no-default-keyring --secret-keyring=/dev/null --keyring=/dev/null})
     @encrypted = !(statustxt =~ /\[GNUPG:\] NODATA/)
     if @encrypted
       @readers = EncryptedFile.list_readers(statustxt)
@@ -328,23 +461,36 @@ class EncryptedFile
   def encrypt(content, recipients)
     args = recipients.collect{ |r| "--recipient=#{r}"}
     args.push "--trust-model=always"
+    args.push "--keyring=./.keyring" if FileTest.exists?(".keyring")
+    args.push "--armor"
     args.push "--encrypt"
     (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(content, args)
 
-    if exitstatus != 0
-      proceed = read_input("Warning: gpg returned non-zero exit status #{exitstatus} when encrypting #{@filename}.  Proceed (or try again)?")
-      return false unless proceed
-    elsif outtxt.length == 0
-      tryagain = read_input("Error: #{@filename} decrypted to an empty file.  Edit again (or exit)?")
+    invalid = []
+    statustxt.split("\n").each do |line|
+      m = /^\[GNUPG:\] INV_RECP \S+ ([0-9A-F]+)/.match line
+      next unless m
+      invalid.push m[1]
+    end
+    if invalid.size > 0
+      again = read_input("Warning: the following recipients are invalid: #{invalid.join(", ")}. Try again (or proceed)?")
+      return false if again
+    end
+    if outtxt.length == 0
+      tryagain = read_input("Error: #{@filename} encrypted to an empty file.  Edit again (or exit)?")
       return false if tryagain
       exit(0)
     end
+    if exitstatus != 0
+      proceed = read_input("Warning: gpg returned non-zero exit status #{exitstatus} when encrypting #{@filename}. Said:\n#{stderrtxt}\n#{statustxt}\n\nProceed (or try again)?")
+      return false unless proceed
+    end
 
     return true, outtxt
   end
 
 
-  def write_back(content)
+  def determine_encryption_targets(content)
     targets = EncryptedFile.targets(content)
     if targets.size == 0
       tryagain = read_input("Warning: Did not find targets to encrypt to in header.  Try again (or exit)?", true)
@@ -374,7 +520,11 @@ class EncryptedFile
       return false if tryagain
     end
 
-    ok, encrypted = encrypt(content, expanded)
+    return true, expanded
+  end
+
+  def write_back(content, targets)
+    ok, encrypted = encrypt(content, targets)
     return false unless ok
 
     File.open(@filename,"w").write(encrypted)
@@ -459,6 +609,8 @@ class Ed
       exit(1)
     end
 
+    encrypted_to = GnuPG.get_fprs_from_keyids(encrypted_file.readers).sort
+
     content = encrypted_file.decrypt
     original_content = content
     while true
@@ -473,15 +625,27 @@ class Ed
         proceed = read_input("Warning: Editor did not exit successfully (exit code #{status.exitstatus}.  Proceed?")
         exit(0) unless proceed
       end
-      tempfile.seek(0, IO::SEEK_SET)
-      content = tempfile.read
 
-      # zero the file
+      # some editors do not write new content in place, but instead
+      # make a new file and more it in the old file's place.
+      begin
+        reopened = File.open(tempfile.path, "r+")
+      rescue Exception => e
+        STDERR.puts e
+        exit(1)
+      end
+      content = reopened.read
+
+      # zero the file, well, both of them.
       newsize = content.length
-      tempfile.seek(0, IO::SEEK_SET)
       clearsize = (newsize > oldsize) ? newsize : oldsize
-      tempfile.print "\0"*clearsize
-      tempfile.fsync
+
+      [tempfile, reopened].each do |f|
+        f.seek(0, IO::SEEK_SET)
+        f.print "\0"*clearsize
+        f.fsync
+      end
+      reopened.close
       tempfile.close(true)
 
       if content.length == 0
@@ -489,12 +653,19 @@ class Ed
         exit(0) unless proceed
       end
 
+      ok, targets = encrypted_file.determine_encryption_targets(content)
+      next unless ok
+
       if (original_content == content)
-        proceed = read_input("Nothing changed.  Re-encrypt anyway?", false)
-        exit(0) unless proceed
+        if (targets.sort == encrypted_to)
+          proceed = read_input("Nothing changed.  Re-encrypt anyway?", false)
+          exit(0) unless proceed
+        else
+          STDERR.puts("Info: Content not changed but re-encrypting anyway because the list of keys changed")
+        end
       end
 
-      success = encrypted_file.write_back(content)
+      success = encrypted_file.write_back(content, targets)
       break if success
     end
   end
@@ -502,8 +673,8 @@ class Ed
   def initialize()
     ARGV.options do |opts|
       opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
-      opts.on_tail("-n", "--new" , "Edit new file") { |@new| }
-      opts.on_tail("-f", "--force" , "Spawn an editor even if the file is probably not readable") { |@force| }
+      opts.on_tail("-n", "--new" , "Edit new file") { |new| @new=new }
+      opts.on_tail("-f", "--force" , "Spawn an editor even if the file is probably not readable") { |force| @force=force }
       opts.parse!
     end
     help(ARGV.options, 1, STDERR) if ARGV.length != 1
@@ -535,10 +706,136 @@ class Ed
   end
 end
 
+class Get
+  def help(parser, code=0, io=STDOUT)
+    io.puts "Usage: #{$program_name} get <filename> <query>"
+    io.puts parser.summarize
+    io.puts "Decrypts the file, fetches a key and outputs it to stdout."
+    io.puts "The file must be in YAML format."
+    io.puts "query is a query, formatted like /host/users/root"
+    exit(code)
+  end
+
+  def get(filename, what)
+    encrypted_file = EncryptedFile.new(filename, @new)
+    if !encrypted_file.readable
+      STDERR.puts "#{filename} is probably not readable"
+      exit(1)
+    end
+
+    begin
+      yaml = YAML::load(encrypted_file.decrypt)
+    rescue Psych::SyntaxError, ArgumentError => e
+      STDERR.puts "Could not parse YAML: #{e.message}"
+      exit(1)
+    end
+
+    require 'pp'
+
+    a = what.split("/")[1..-1]
+    hit = yaml
+    if a.nil?
+      # q = /, so print top level keys
+      puts "Keys:"
+      hit.keys.each do |k|
+        puts "- #{k}"
+      end
+      return
+    end
+    a.each do |k|
+      hit = hit[k]
+    end
+    if hit.nil?
+      STDERR.puts("No such key or invalid lookup expression")
+    elsif hit.respond_to?(:keys)
+      puts "Keys:"
+      hit.keys.each do |k|
+        puts "- #{k}"
+      end
+    else
+        puts hit
+    end
+  end
+
+  def initialize()
+    ARGV.options do |opts|
+      opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
+      opts.parse!
+    end
+    help(ARGV.options, 1, STDERR) if ARGV.length != 2
+    filename = ARGV.shift
+    what = ARGV.shift
+
+    if !FileTest.exists?(filename)
+      STDERR.puts "#{filename} does not exist"
+      exit(1)
+    elsif !FileTest.file?(filename)
+      STDERR.puts "#{filename} is not a regular file"
+      exit(1)
+    elsif !FileTest.readable?(filename)
+      STDERR.puts "#{filename} is not accessible (unix perms)"
+      exit(1)
+    end
+
+    dirname = File.dirname(filename)
+    basename = File.basename(filename)
+    Dir.chdir(dirname) {
+      get(basename, what)
+    }
+  end
+end
+
+class KeyringUpdater
+  def help(parser, code=0, io=STDOUT)
+    io.puts "Usage: #{$program_name} update-keyring [<keyserver>]"
+    io.puts parser.summarize
+    io.puts "Updates the local .keyring file"
+    exit(code)
+  end
+
+  def initialize()
+    ARGV.options do |opts|
+      opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
+      opts.parse!
+    end
+    help(ARGV.options, 1, STDERR) if ARGV.length > 1
+    keyserver = ARGV.shift
+    keyserver = 'keys.gnupg.net' unless keyserver
+
+    groupconfig = GroupConfig.new
+    users = groupconfig.get_users()
+    args = %w{--with-colons --no-options --no-default-keyring --keyring=./.keyring}
+
+    system('touch', '.keyring')
+    users.each_pair() do |uid, keyid|
+      cmd = args.clone()
+      cmd << "--keyserver=#{keyserver}"
+      cmd << "--recv-keys"
+      cmd << keyid
+      puts "Fetching key for #{uid}"
+      (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', cmd)
+      unless (statustxt =~ /^\[GNUPG:\] IMPORT_OK /)
+        STDERR.puts "Warning: did not find IMPORT_OK token in status output"
+        STDERR.puts "gpg exited with exit code #{ecode})"
+        STDERR.puts "Command was gpg #{cmd.join(' ')}"
+        STDERR.puts "stdout was #{outtxt}"
+        STDERR.puts "stderr was #{stderrtxt}"
+        STDERR.puts "statustxt was #{statustxt}"
+      end
+
+      cmd = args.clone()
+      cmd << '--batch' << '--edit' << keyid << 'minimize' << 'save'
+      (outtxt, stderrtxt, statustxt, ecode) = GnuPG.gpgcall('', cmd)
+    end
+
+
+  end
+end
 
 def help(code=0, io=STDOUT)
   io.puts "Usage: #{$program_name} ed"
   io.puts "       #{$program_name} ls"
+  io.puts "       #{$program_name} update-keyring"
   io.puts "       #{$program_name} help"
   io.puts "Call #{$program_name} <command> --help for additional options/parameters"
   exit(code)
@@ -547,12 +844,14 @@ end
 
 def parse_command
   case ARGV.shift
-    when 'ls': Ls.new
-    when 'ed': Ed.new
-    when 'help': 
+    when 'ls' then Ls.new
+    when 'ed' then Ed.new
+    when 'get' then Get.new
+    when 'update-keyring' then KeyringUpdater.new
+    when 'help' then
       case ARGV.length
-        when 0: help
-        when 1:
+        when 0 then help
+        when 1 then
           ARGV.push "--help"
           parse_command
         else help(1, STDERR)