# 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
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']
class GnuPG
@@my_keys = nil
+ @@my_fprs = nil
+ @@keyid_fpr_mapping = {}
def GnuPG.readwrite3(intxt, infd, stdoutfd, stderrfd, statusfd)
outtxt, stderrtxt, statustxt = ''
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
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
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)
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"
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')
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)
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)
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']
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|
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)
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)
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)
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)
exit(1)
end
+ encrypted_to = GnuPG.get_fprs_from_keyids(encrypted_file.readers).sort
+
content = encrypted_file.decrypt
original_content = content
while true
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
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
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
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)
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)