Initial version
Change-Id: I7f5d09024cf75e0872fddfadd9462e114b0de76f
diff --git a/scripts/pws b/scripts/pws
new file mode 100755
index 0000000..3c6b9f3
--- /dev/null
+++ b/scripts/pws
@@ -0,0 +1,1072 @@
+#!/usr/bin/ruby
+
+# password store management tool
+
+# Copyright (c) 2008, 2009, 2011, 2013 Peter Palfrader <peter@palfrader.org>
+# Copyright (c) 2014 Fastly
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+require 'optparse'
+require 'thread'
+require 'tempfile'
+
+require 'yaml'
+Thread.abort_on_exception = true
+
+GNUPG = "gpg"
+GROUP_PATTERN = "@[a-zA-Z0-9_-]+"
+USER_PATTERN = "[a-zA-Z0-9:_-]+"
+$program_name = File.basename($0, '.*')
+CONFIG_FILE = ENV['HOME']+ "/.pws.yaml"
+
+$editor = ENV['EDITOR']
+if $editor == nil
+ %w{/usr/bin/sensible-editor /usr/bin/editor /usr/bin/vi}.each do |editor|
+ if FileTest.executable?(editor)
+ $editor = editor
+ break
+ end
+ end
+end
+if $editor == nil
+ STDERR.puts "Cannot find an editor"
+ exit(1)
+end
+
+class GnuPG
+ @@my_keys = nil
+ @@my_fprs = nil
+ @@keyid_fpr_mapping = {}
+ @@extra_args = []
+
+ def GnuPG.extra_args=(val)
+ @@extra_args = val
+ end
+
+ def GnuPG.readwrite3(intxt, infd, stdoutfd, stderrfd, statusfd=nil)
+ outtxt, stderrtxt, statustxt = ''
+ thread_in = Thread.new {
+ infd.print intxt
+ infd.close
+ }
+ thread_out = Thread.new {
+ outtxt = stdoutfd.read
+ stdoutfd.close
+ }
+ thread_err = Thread.new {
+ errtxt = stderrfd.read
+ stderrfd.close
+ }
+ thread_status = Thread.new {
+ statustxt = statusfd.read
+ statusfd.close
+ } if (statusfd)
+
+ thread_in.join
+ thread_out.join
+ thread_err.join
+ thread_status.join if thread_status
+
+ return outtxt, stderrtxt, statustxt
+ end
+
+ def GnuPG.open3call(cmd, intxt, args, require_success = false, do_status=true)
+ inR, inW = IO.pipe
+ outR, outW = IO.pipe
+ errR, errW = IO.pipe
+ statR, statW = IO.pipe if do_status
+
+ pid = Kernel.fork do
+ inW.close
+ outR.close
+ errR.close
+ statR.close if do_status
+ STDIN.reopen(inR)
+ STDOUT.reopen(outW)
+ STDERR.reopen(errW)
+ fds = {
+ STDIN=>inR,
+ STDOUT=>outW,
+ STDERR=>errW,
+ }
+ begin
+ if do_status
+ fds[statW.fileno] = statW
+ exec(cmd, "--status-fd=#{statW.fileno}", *(@@extra_args + args), fds)
+ else
+ exec(cmd, *(@@extra_args + args), fds)
+ end
+ rescue Exception => e
+ outW.puts("[PWSEXECERROR]: #{e}")
+ exit(1)
+ end
+ raise ("Calling gnupg failed")
+ end
+ inR.close
+ outW.close
+ errW.close
+ if do_status
+ statW.close
+ (outtxt, stderrtxt, statustxt) = readwrite3(intxt, inW, outR, errR, statR);
+ else
+ (outtxt, stderrtxt) = readwrite3(intxt, inW, outR, errR);
+ end
+ wpid, status = Process.waitpid2 pid
+ throw "Unexpected pid: #{pid} vs #{wpid}" unless pid == wpid
+ throw "Process has not exited!?" unless status.exited?
+ if (require_success and status.exitstatus != 0)
+ STDERR.puts "#{cmd} call did not exit sucessfully."
+ STDERR.puts "output on stdout:"
+ STDERR.puts outtxt
+ STDERR.puts "output on stderr:"
+ STDERR.puts stderrtxt
+ if do_status
+ STDERR.puts "output on statusfd:"
+ STDERR.puts statustxt
+ end
+ exit(1)
+ end
+ if m=/^\[PWSEXECERROR\]: (.*)/.match(outtxt) then
+ STDERR.puts "Could not run GnuPG: #{m[1]}"
+ exit(1)
+ end
+ if do_status
+ return outtxt, stderrtxt, statustxt, status.exitstatus
+ else
+ return outtxt, stderrtxt, status.exitstatus
+ end
+ end
+
+ def GnuPG.gpgcall(intxt, args, require_success = false)
+ return open3call(GNUPG, intxt, args, require_success)
+ end
+
+ def GnuPG.init_keys()
+ return if @@my_keys
+ (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', %w{--fast-list-mode --with-colons --with-fingerprint --list-secret-keys}, true)
+ @@my_keys = []
+ @@my_fprs = []
+ outtxt.split("\n").each do |line|
+ parts = line.split(':')
+ if (parts[0] == "ssb" or parts[0] == "sec")
+ @@my_keys.push parts[4]
+ elsif (parts[0] == "fpr")
+ @@my_fprs.push parts[9]
+ 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", "--no-default-keyring") if FileTest.exists?(".keyring")
+ args.concat need_to_learn
+ (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', args)
+
+ pub = nil
+ fpr = nil
+ outtxt.split("\n").each do |line|
+ parts = line.split(':')
+ if (parts[0] == "pub")
+ pub = parts[4]
+ fpr = nil
+ elsif (parts[0] == "fpr") and fpr.nil?
+ 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)
+ if default_yes
+ append = '[Y/n]'
+ else
+ append = '[y/N]'
+ end
+
+ while true
+ print "#{query} #{append} "
+ begin
+ i = STDIN.readline.chomp.downcase
+ rescue EOFError
+ return default_yes
+ end
+ if i==""
+ return default_yes
+ elsif i=="y"
+ return true
+ elsif i=="n"
+ return false
+ end
+ end
+end
+
+class GroupConfig
+ attr_reader :dirname
+
+ def initialize(dirname=".", trusted_users=nil)
+ @dirname = dirname
+ if trusted_users
+ @trusted_users_source = trusted_users
+ load_deprecated_trusted_users()
+ elsif FileTest.exists?(CONFIG_FILE)
+ t = {}
+ begin
+ yaml = YAML::load_file(CONFIG_FILE)
+ yaml["trusted_users"].each do |k,v|
+ t[File.expand_path(k)] = v
+ end
+ @trusted_users_source = CONFIG_FILE
+ d = File.expand_path(dirname)
+ while d != "/"
+ if t.include?(d)
+ @trusted_users = t[d]
+ @dirname = d
+ break
+ end
+ d = File.split(d)[0]
+ end
+ if @trusted_users.nil? or d == "/"
+ raise ("Could not find #{File.expand_path(dirname)} or its parents in configuration file #{CONFIG_FILE}")
+ end
+ rescue Psych::SyntaxError, ArgumentError => e
+ raise("Could not parse YAML: #{e.message}")
+ end
+ else
+ @trusted_users_source = ENV['HOME']+'/.pws-trusted-users'
+ load_deprecated_trusted_users()
+ end
+ parse_file
+ expand_groups
+ end
+
+ def load_deprecated_trusted_users()
+ begin
+ f = File.open(@trusted_users_source)
+ rescue Exception => e
+ raise e
+ end
+
+ trusted = []
+ f.readlines.each do |line|
+ line.chomp!
+ next if line =~ /^$/
+ next if line =~ /^#/
+
+ trusted.push line
+ end
+ @trusted_users = trusted
+ end
+
+ def verify(content)
+ args = []
+ args.push "--keyring=#{@dirname}/.keyring" if FileTest.exists?(File.join(@dirname, ".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
+ raise "Not goodsig"
+ end
+
+ if not @trusted_users.include?(validsig)
+ raise ".users file is signed by #{validsig} which is not in #{@trusted_users_source}"
+ end
+
+ if not exitstatus==0
+ raise "gpg verify failed for .users file"
+ end
+
+ return outtxt
+ end
+
+ def parse_file
+ begin
+ f = File.open(File.join(@dirname, '.users'))
+ rescue Exception => e
+ raise e
+ end
+
+ users = f.read
+ f.close
+
+ users = verify(users)
+
+ @users = {}
+ @groups = {}
+
+ lno = 0
+ users.split("\n").each do |line|
+ lno = lno+1
+ next if line =~ /^$/
+ next if 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)
+ STDERR.puts "User #{user} redefined at line #{lno}!"
+ exit(1)
+ end
+ @users[user] = fpr
+ elsif (m = /^(#{GROUP_PATTERN})\s*=\s*(.*)$/.match line)
+ group = m[1]
+ members = m[2].strip
+ if @groups.has_key?(group)
+ STDERR.puts "Group #{group} redefined at line #{lno}!"
+ exit(1)
+ end
+ members = members.split(/[\t ,]+/)
+ @groups[group] = { "members" => members }
+ end
+ end
+ end
+
+ def is_group(name)
+ return (name =~ /^@/)
+ end
+ def check_exists(x, whence, fatal=true)
+ ok=true
+ if is_group(x)
+ ok=false unless (@groups.has_key?(x))
+ else
+ ok=false unless @users.has_key?(x)
+ end
+ unless ok
+ STDERR.puts( (fatal ? "Error: " : "Warning: ") + "#{whence} contains unknown member #{x}")
+ exit(1) if fatal
+ end
+ return ok
+ end
+ def expand_groups
+ @groups.each_pair do |groupname, group|
+ group['members'].each do |member|
+ check_exists(member, "Group #{groupname}")
+ end
+ group['members_to_do'] = group['members'].clone
+ end
+
+ while true
+ had_progress = false
+ all_expanded = true
+ @groups.each_pair do |groupname, group|
+ group['keys'] = [] unless group['keys']
+
+ still_contains_groups = false
+ 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']
+ group['members_to_do'].delete(member)
+ had_progress = true
+ else
+ still_contains_groups = true
+ end
+ else
+ group['keys'].push @users[member]
+ group['members_to_do'].delete(member)
+ had_progress = true
+ end
+ end
+ all_expanded = false if still_contains_groups
+ end
+ break if all_expanded
+ unless had_progress
+ cyclic_groups = @groups.keys.reject{|name| @groups[name]['members_to_do'].size == 0}.join(", ")
+ STDERR.puts "Cyclic group memberships in #{cyclic_groups}?"
+ exit(1)
+ end
+ end
+ end
+
+ def expand_targets(targets)
+ fprs = []
+ ok = true
+ targets.each do |t|
+ unless check_exists(t, "access line", false)
+ ok = false
+ next
+ end
+ if is_group(t)
+ fprs.concat @groups[t]['keys']
+ else
+ fprs.push @users[t]
+ end
+ end
+ return ok, fprs.uniq
+ end
+
+ def get_users()
+ return @users
+ end
+end
+
+class EncryptedData
+ attr_reader :accessible, :encrypted, :readable, :readers
+
+ def EncryptedData.determine_readable(readers)
+ GnuPG.get_my_keys.each do |keyid|
+ return true if readers.include?(keyid)
+ end
+ return false
+ end
+
+ def EncryptedData.list_readers(statustxt)
+ readers = []
+ statustxt.split("\n").each do |line|
+ m = /^\[GNUPG:\] ENC_TO ([0-9A-F]+)/.match line
+ next unless m
+ readers.push m[1]
+ end
+ return readers
+ end
+
+ def EncryptedData.targets(text)
+ 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(encrypted_content, label, keyring_directory = ".", ignore_decrypt_errors = false)
+ @ignore_decrypt_errors = ignore_decrypt_errors
+ @label = label
+ @keyring_dir = keyring_directory
+
+ @encrypted_content = encrypted_content
+ (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 = EncryptedData.list_readers(statustxt)
+ @readable = EncryptedData.determine_readable(@readers)
+ end
+ end
+
+ def decrypt
+ (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(@encrypted_content, %w{--decrypt})
+ if !@ignore_decrypt_errors and exitstatus != 0
+ proceed = read_input("Warning: gpg returned non-zero exit status #{exitstatus} when decrypting #{@label}. Proceed?", false)
+ exit(0) unless proceed
+ elsif !@ignore_decrypt_errors and outtxt.length == 0
+ proceed = read_input("Warning: #{@label} decrypted to an empty file. Proceed?")
+ exit(0) unless proceed
+ end
+
+ return outtxt
+ end
+
+ def encrypt(content, recipients)
+ args = recipients.collect{ |r| "--recipient=#{r}"}
+ args.push "--trust-model=always"
+ args.push("--keyring=#{@keyring_dir}/.keyring", "--no-default-keyring") if FileTest.exists?("#{@keyring_dir}/.keyring")
+ args.push "--armor"
+ args.push "--encrypt"
+ (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(content, args)
+
+ 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: #{@label} 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 #{@label}. Said:\n#{stderrtxt}\n#{statustxt}\n\nProceed (or try again)?")
+ return false unless proceed
+ end
+
+ return true, outtxt
+ end
+
+
+ def determine_encryption_targets(content)
+ targets = EncryptedData.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
+ exit(0)
+ end
+
+ ok, expanded = @groupconfig.expand_targets(targets)
+ if (expanded.size == 0)
+ tryagain = read_input("Errors in access header. Edit again (or exit)?", true)
+ return false if tryagain
+ exit(0)
+ elsif (not ok)
+ tryagain = read_input("Warnings in access header. Edit again (or continue)?", true)
+ return false if tryagain
+ end
+
+ to_me = false
+ GnuPG.get_my_fprs.each do |fpr|
+ if expanded.include?(fpr)
+ to_me = true
+ break
+ end
+ end
+ unless to_me
+ tryagain = read_input("File is not being encrypted to you. Edit again (or continue)?", true)
+ return false if tryagain
+ end
+
+ return true, expanded
+ end
+
+end
+
+class EncryptedFile < EncryptedData
+ def initialize(filename, new=false, trusted_file=nil)
+ @groupconfig = GroupConfig.new(dirname=File.dirname(filename), trusted_users=trusted_file)
+ @new = new
+ if @new
+ @readers = []
+ end
+
+ @filename = filename
+ @accessible = FileTest.readable?(filename)
+ @filename = filename
+
+ if @accessible
+ encrypted_content = File.read(filename)
+ else
+ encrypted_content = nil
+ end
+ super(encrypted_content, filename, @groupconfig.dirname, @new)
+ end
+
+ def write_back(content, targets)
+ ok, encrypted = encrypt(content, targets)
+ return false unless ok
+
+ File.open(@filename,"w").write(encrypted)
+ return true
+ end
+end
+
+class Ls
+ def help(parser, code=0, io=STDOUT)
+ io.puts "Usage: #{$program_name} ls [<directory> ...]"
+ io.puts parser.summarize
+ io.puts "Lists the contents of the given directory/directories, or the current"
+ io.puts "directory if none is given. For each file show whether it is PGP-encrypted"
+ io.puts "file, and if yes whether we can read it."
+ exit(code)
+ end
+
+ def ls_dir(dirname)
+ begin
+ dir = Dir.open(dirname)
+ rescue Exception => e
+ STDERR.puts e
+ return
+ end
+ puts "#{dirname}:"
+ Dir.chdir(dirname) do
+ unless FileTest.exists?(".users")
+ STDERR.puts "The .users file does not exists here. This is not a password store, is it?"
+ exit(1)
+ end
+ dir.sort.each do |filename|
+ next if (filename =~ /^\./) and not (@all >= 3)
+ stat = File::Stat.new(filename)
+ if stat.symlink?
+ puts "(sym) #{filename}" if (@all >= 2)
+ elsif stat.directory?
+ puts "(dir) #{filename}" if (@all >= 2)
+ elsif !stat.file?
+ puts "(other) #{filename}" if (@all >= 2)
+ else
+ f = EncryptedFile.new(filename)
+ if !f.accessible
+ puts "(!perm) #{filename}"
+ elsif !f.encrypted
+ puts "(file) #{filename}" if (@all >= 2)
+ elsif f.readable
+ puts "(ok) #{filename}"
+ else
+ puts "(locked) #{filename}" if (@all >= 1)
+ end
+ end
+ end
+ end
+ end
+
+ def initialize()
+ @all = 0
+ ARGV.options do |opts|
+ opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
+ opts.on_tail("-a", "--all" , "Show all files (use up to 3 times to show even more than all)") { @all = @all+1 }
+ opts.parse!
+ end
+
+ dirs = ARGV
+ dirs.push('.') unless dirs.size > 0
+ dirs.each { |dir| ls_dir(dir) }
+ end
+end
+
+class Ed
+ def help(parser, code=0, io=STDOUT)
+ io.puts "Usage: #{$program_name} ed <filename>"
+ io.puts parser.summarize
+ io.puts "Decrypts the file, spawns an editor, and encrypts it again"
+ exit(code)
+ end
+
+ def do_edit(content)
+ oldsize = content.length
+
+ tempfile = Tempfile.open('pws')
+ tempfile.puts content
+ tempfile.flush
+ system($editor, tempfile.path)
+ status = $?
+ throw "Process has not exited!?" unless status.exited?
+ unless status.exitstatus == 0
+ proceed = read_input("Warning: Editor did not exit successfully (exit code #{status.exitstatus}. Proceed?")
+ exit(0) unless proceed
+ end
+
+ # 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
+ clearsize = (newsize > oldsize) ? newsize : oldsize
+
+ [tempfile, reopened].each do |f|
+ f.seek(0, IO::SEEK_SET)
+ f.print "\0"*clearsize
+ f.fsync
+ end
+ reopened.close
+ tempfile.close(true)
+
+ return content
+ end
+
+ def edit(filename)
+ encrypted_file = EncryptedFile.new(filename, @new)
+ if !@new and !encrypted_file.readable && !@force
+ STDERR.puts "#{filename} is probably not readable"
+ exit(1)
+ end
+
+ encrypted_to = GnuPG.get_fprs_from_keyids(encrypted_file.readers).sort
+
+ content = encrypted_file.decrypt
+ original_content = content
+ while true
+ content = do_edit(content)
+
+ if content.length == 0
+ proceed = read_input("Warning: Content is now empty. Proceed?")
+ exit(0) unless proceed
+ end
+
+ ok, targets = encrypted_file.determine_encryption_targets(content)
+ next unless ok
+
+ if (original_content == content && ! @reencrypt_on_change)
+ if (targets.sort == encrypted_to)
+ exit(0)
+ else
+ STDERR.puts("Notice: list of keys changed -- re-encryption recommended. Run #{$program_name} rc #{filename}")
+ exit(0)
+ end
+ end
+
+ success = encrypted_file.write_back(content, targets)
+ break if success
+ end
+ end
+
+ def initialize(reencrypt_on_change=false)
+ ARGV.options do |opts|
+ opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
+ 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
+ filename = ARGV.shift
+
+ if @new
+ if FileTest.exists?(filename)
+ STDERR.puts "#{filename} does exist"
+ exit(1)
+ end
+ else
+ 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
+ end
+
+ @reencrypt_on_change = reencrypt_on_change
+
+ dirname = File.dirname(filename)
+ basename = File.basename(filename)
+ Dir.chdir(dirname) {
+ edit(basename)
+ }
+ end
+end
+
+class Reencrypt < Ed
+ def help(parser, code=0, io=STDOUT)
+ io.puts "Usage: #{$program_name} rc <filename>"
+ io.puts parser.summarize
+ io.puts "Reencrypts the file"
+ exit(code)
+ end
+ def do_edit(content)
+ return content
+ end
+ def initialize()
+ super(true)
+ 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, ecode) = 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
+
+class GitDiff
+ def help(parser, code=0, io=STDOUT)
+ io.puts "Usage: #{$program_name} gitdiff <commit> <file>"
+ io.puts parser.summarize
+ io.puts "Shows a diff between the version of <file> in your directory and the"
+ io.puts "version in git at <commit> (or HEAD). Requires that your tree be git"
+ io.puts "managed, obviously."
+ exit(code)
+ end
+
+ def check_readable(e, label)
+ if !e.readable && !@force
+ STDERR.puts "#{label} is probably not readable."
+ exit(1)
+ end
+ end
+
+ def get_file_at_commit()
+ label = @commit+':'+@filename
+ (encrypted_content, stderrtxt, exitcode) = GnuPG.open3call('git', '', ['show', label], require_success=true, do_status=false)
+ data = EncryptedData.new(encrypted_content, label)
+ check_readable(data, label)
+ return data.decrypt
+ end
+
+ def get_file_current()
+ data = EncryptedFile.new(@filename)
+ check_readable(data, @filename)
+ return data.decrypt
+ end
+
+ def diff()
+ old = get_file_at_commit()
+ cur = get_file_current()
+
+ t1 = Tempfile.open('pws')
+ t1.puts old
+ t1.flush
+
+ t2 = Tempfile.open('pws')
+ t2.puts cur
+ t2.flush
+
+ system("diff", "-u", t1.path, t2.path)
+
+ t1.seek(0, IO::SEEK_SET)
+ t1.print "\0"*old.length
+ t1.fsync
+ t1.close(true)
+
+ t2.seek(0, IO::SEEK_SET)
+ t2.print "\0"*cur.length
+ t2.fsync
+ t2.close(true)
+ end
+
+ def initialize()
+ ARGV.options do |opts|
+ opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
+ opts.on_tail("-f", "--force" , "Do it even if the file is probably not readable") { |force| @force=force }
+ opts.parse!
+ end
+
+ if ARGV.length == 1
+ @commit = 'HEAD'
+ @filename = ARGV.shift
+ elsif ARGV.length == 2
+ @commit = ARGV.shift
+ @filename = ARGV.shift
+ else
+ help(ARGV.options, 1, STDERR)
+ end
+
+ diff()
+ end
+end
+
+
+def help(code=0, io=STDOUT)
+ io.puts "Usage: #{$program_name} ed"
+ io.puts " #{$program_name} rc"
+ io.puts " #{$program_name} ls"
+ io.puts " #{$program_name} gitdiff"
+ io.puts " #{$program_name} update-keyring"
+ io.puts " #{$program_name} help"
+ io.puts "Call #{$program_name} <command> --help for additional options/parameters"
+ exit(code)
+end
+
+
+def parse_command
+ case ARGV.shift
+ when 'ls' then Ls.new
+ when 'ed' then Ed.new
+ when 'rc' then Reencrypt.new
+ when 'gitdiff' then GitDiff.new
+ when 'get' then Get.new
+ when 'update-keyring' then KeyringUpdater.new
+ when 'help' then
+ case ARGV.length
+ when 0 then help
+ when 1 then
+ ARGV.push "--help"
+ parse_command
+ else help(1, STDERR)
+ end
+ else
+ help(1, STDERR)
+ end
+end
+
+if __FILE__ == $0
+ begin
+ parse_command
+ rescue RuntimeError => e
+ STDERR.puts e.message
+ exit(1)
+ end
+end
+
+# vim:set shiftwidth=2:
+# vim:set et:
+# vim:set ts=2: