# # # # # # # # #
# /41/pws_3.rb
#
# by               Jan Lelis
# e-mail:          mail@janlelis.de
# type/version:    ruby 
# snippet url:     http://rbJL.net/41/pws_3.rb
# original post:   http://rbJL.net/41-tutorial-build-your-own-password-safe-with-ruby
# license:         CC-BY (DE)
#
# (c) 2010 Jan Lelis.

require 'rubygems' if RUBY_VERSION[2] == ?8

require 'openssl'
require 'fileutils'
require 'clipboard'         # gem install clipboard
require 'zucker/alias_for'  # gem install zucker
require 'zucker/egonil'
require 'zucker/kernel'

class PasswordSafe
  VERSION = "0.0.3".freeze

  Entry = Struct.new :description, :password

  def initialize( filename = File.expand_path('~/.pws') )
    @pwfile = filename

    access_safe
    read_safe
  end

  def add(key, description = nil, password = nil)
    @pwdata[key]             = Entry.new
    @pwdata[key].password    = password || ask_for_password( "please enter a password for #{key}" )
    @pwdata[key].description = description
    write_safe
  end
  aliases_for :add, :a, :set, :create, :update, :[]= # using zucker/alias_for

  def get(key)
    if pw_plaintext = @pwdata[key] && @pwdata[key].password
      Clipboard.copy pw_plaintext
      puts "The password has been copied to your clipboard"
    else
      puts "No password entry found for #{key}"
    end
  end
  aliases_for :get, :g, :entry, :[]

  def remove(key)
    if @pwdata.delete key
      puts "#{key} has been removed"
    else
      puts "Nothing removed"
    end
  end
  aliases_for :remove, :r, :delete

  def show
    puts "Available passwords \n" +

    if @pwdata.empty? 
      '  (none)'
    else
      @pwdata.map{ |key, pwentry|
        "  #{key}" + if pwentry.description then ": #{pwentry.description}" else '' end
      }*"\n" 
    end
  end
  aliases_for :show, :s, :list

  def description(*keys)
    keys.each{ |key|
      puts (@pwdata[key] && @pwdata[key].description) || key
    }
  end

  def master
    @pwhash = Encryptor.hash ask_for_password 'please enter a new master password'
    write_safe
  end
  aliases_for :master, :m

  private

  # Tries to load and decrypt the password safe from the pwfile
  def read_safe
    pwdata_encrypted = File.read @pwfile
    pwdata_dump      = Encryptor.decrypt( pwdata_encrypted, @pwhash )
    @pwdata          = Marshal.load(pwdata_dump) || {}
  end

  # Tries to encrypt and save the password safe into the pwfile
  def write_safe
    pwdata_dump      = Marshal.dump @pwdata || {}
    pwdata_encrypted = Encryptor.encrypt pwdata_dump, @pwhash
    File.open( @pwfile, 'w' ){ |f| f.write pwdata_encrypted }
  end
  
  # Checks if the file is accessible or create a new one
  def access_safe
    if !File.file? @pwfile
      puts "No password safe detected, creating one at #@pwfile"
      FileUtils.touch @pwfile
      @pwhash = Encryptor.hash ask_for_password 'please enter a new master password'
      write_safe
    else
      @pwhash = Encryptor.hash ask_for_password 'master password'
    end
  end

  def ask_for_password(prompt = 'new password')
    print "#{prompt}: ".capitalize
    system 'stty -echo'                    # no more terminal output
    pw_plaintext = ($stdin.gets||'').chop  # gets without $stdin would mistakenly read_safe from ARGV
    system 'stty echo'                     # restore terminal output
    puts

    pw_plaintext
  end

  class << Encryptor = Module.new
    CIPHER = 'AES256'

    def decrypt( data, pwhash )
      crypt :decrypt, data, pwhash
    end

    def encrypt( data, pwhash )
      crypt :encrypt, data, pwhash
    end

    def hash( plaintext )
      OpenSSL::Digest::SHA512.new( plaintext ).digest
    end

    private

    # Encrypts or decrypts the data with the password hash as key
    # NOTE: encryption exceptions do not get caught!
    def crypt( decrypt_or_encrypt, data, pwhash )
      c = OpenSSL::Cipher.new CIPHER
      c.send decrypt_or_encrypt.to_sym
      c.key = pwhash
      c.update( data ) << c.final
    end
  end
end

if standalone? # using zucker/kernel (instead of __FILE__ == $0)
  pws = PasswordSafe.new 'p3test'
  pws.send $*.shift.to_sym, *$*
end

# J-_-L