# Copyright 2008-2009 Amazon.com, Inc. or its affiliates.  All Rights
# Reserved.  Licensed under the Amazon Software License (the
# "License").  You may not use this file except in compliance with the
# License. A copy of the License is located at
# http://aws.amazon.com/asl or in the "license" file accompanying this
# file.  This file is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See
# the License for the specific language governing permissions and
# limitations under the License.

require 'ec2/amitools/format'
require 'digest/sha1'
require 'openssl'
require 'stringio'

### 
# Cryptographic utilities module.
#
module Crypto
  BUFFER_SIZE = 1024 * 1024
  ASYM_ALG = 'RSA'
  SYM_ALG = 'AES-128-CBC'
  DIGEST_ALG = 'SHA1'
  PADDING = OpenSSL::PKey::RSA::PKCS1_PADDING
  VERSION1 = 1
  VERSION2 = 2
  SHA1_FINGERPRINT_REGEX = /([a-f0-9]{2}(:[a-f0-9]{2}){15})/i

  #----------------------------------------------------------------------------#

  ##
  # Decrypt the specified cipher text according to the AMI Manifest Encryption
  # Scheme Version 1 or 2.
  #
  # ((|cipher_text|)) The cipher text to decrypt.
  # ((|keyio_or_keyfilename|)) The key data IO stream or the name of the private
  # key file.
  #
  def Crypto.decryptasym(cipher_text, keyfilename)
    raise ArgumentError.new('cipher_text') unless cipher_text
    raise ArgumentError.new('keyfilename') unless keyfilename and FileTest.exists? keyfilename

    # Load key.
    privkey = File.open(keyfilename, 'r') { |f| OpenSSL::PKey::RSA.new(f) }

    # Get version.
    version = cipher_text[0]
    if version == VERSION2
      return Crypto.decryptasym_v2( cipher_text, keyfilename )
    end
    raise ArgumentError.new("invalid encryption scheme versionb: #{version}") unless version == 1
    
    # Decrypt and extract encrypted symmetric key and initialization vector.
    symkey_cryptogram_len = cipher_text.slice(1, 2).unpack('C')[0]
    symkey_cryptogram = privkey.private_decrypt(
      cipher_text.slice(2, symkey_cryptogram_len),
      PADDING)
    symkey = symkey_cryptogram.slice(0, 16)
    iv = symkey_cryptogram.slice(16, 16)
    
    # Decrypt data with the symmetric key.
    cryptogram = cipher_text.slice(2 + symkey_cryptogram_len..cipher_text.size)
    decryptsym(cryptogram, symkey, iv)
  end

  #----------------------------------------------------------------------------#

  ##
  # Decrypt the specified cipher text according to the AMI Manifest Encryption
  # Scheme Version 2.
  #
  # ((|cipher_text|)) The cipher text to decrypt.
  # ((|keyio_or_keyfilename|)) The key data IO stream or the name of the private
  # key file.
  #
  def Crypto.decryptasym_v2(cipher_text, keyfilename)
    raise ArgumentError.new('cipher_text') unless cipher_text
    raise ArgumentError.new('keyfilename') unless keyfilename and FileTest.exists? keyfilename

    # Load key.
    privkey = File.open(keyfilename, 'r') { |f| OpenSSL::PKey::RSA.new(f) }

    # Get version.
    version = cipher_text[0]
    raise ArgumentError.new("invalid encryption scheme versionb: #{version}") unless version == VERSION2
    
    # Decrypt and extract encrypted symmetric key and initialization vector.
    hi_byte, lo_byte = cipher_text.slice(1, 3).unpack('CC')
    symkey_cryptogram_len = ( hi_byte << 8 ) | lo_byte        
    symkey_cryptogram = privkey.private_decrypt(
      cipher_text.slice(3, symkey_cryptogram_len),
      PADDING)
    
    symkey = symkey_cryptogram.slice(0, 16)
    iv = symkey_cryptogram.slice(16, 16)
        
    # Decrypt data with the symmetric key.
    cryptogram = cipher_text.slice( ( 3 + symkey_cryptogram_len )..cipher_text.size)
    decryptsym(cryptogram, symkey, iv)
  end

  #----------------------------------------------------------------------------#

  ##
  # Asymmetrically encrypt the specified data using the AMI Manifest Encryption
  # Scheme Version 2.
  #
  # The data is encrypted with an ephemeral symmetric key and initialization
  # vector. The symmetric key and initialization vector are encrypted with the
  # specified public key and preprended to the data.
  #
  # ((|data|)) The data to encrypt.
  # ((|pubkey|)) The public key.
  #
  def Crypto.encryptasym(data, pubkey)
    raise ArgumentError.new('data') unless data
    raise ArgumentError.new('pubkey') unless pubkey
    
    symkey = gensymkey
    iv = geniv
    symkey_cryptogram = pubkey.public_encrypt( symkey + iv, PADDING )

    data_cryptogram = encryptsym(data, symkey, iv)
        
    hi_byte, lo_byte = Format.int2int16(symkey_cryptogram.size)
    
    Format::int2byte(VERSION2) + hi_byte + lo_byte + symkey_cryptogram + data_cryptogram
  end

  #----------------------------------------------------------------------------#
  
  ##
  # Verify the authenticity of the data from the IO stream or string ((|data|))
  # using the signature ((|sig|)) and the public key ((|pubkey|)).
  #
  # Return true iff the signature is valid.
  #
  def Crypto.authenticate(data, sig, pubkey)
    raise ArgumentError.new("Invalid parameter data") if data.nil?
    raise ArgumentError.new("Invalid parameter sig") if sig.nil? or sig.length==0
    raise ArgumentError.new("Invalid parameter pubkey") if pubkey.nil?
         
    # Create IO stream if necessary.
    io = (data.instance_of?(StringIO) ? data : StringIO.new(data))
    
    sha = OpenSSL::Digest::SHA1.new
    res = false
    while not (io.eof?)
      res = pubkey.verify(sha, sig, io.read(BUFFER_SIZE))
    end
    res
  end

  #----------------------------------------------------------------------------#
  
  ##
  # Decrypt the specified cipher text file to create the specified plain text
  # file.
  #
  # The symmetric cipher is AES in CBC mode. 128 bit keys are used. If the plain
  # text file already exists it will be overwritten.
  #
  # ((|src|)) The name of the cipher text file to decrypt.
  # ((|dst|)) The name of the plain text file to create.
  # ((|key|)) The 128 bit (16 byte) symmetric key.
  # ((|iv|)) The 128 bit (16 byte) initialization vector.
  #
  def Crypto.decryptfile(src, dst, key, iv)    
    raise ArgumentError.new("invalid file name: #{src}") unless FileTest.exists?(src)
    raise ArgumentError.new("invalid key") unless key and key.size == 16
    raise ArgumentError.new("invalid iv") unless iv and iv.size == 16
    pio = IO.popen( "openssl enc -d -aes-128-cbc -in #{src} -out #{dst} -K #{Format::bin2hex(key)} -iv #{Format::bin2hex(iv)} 2>&1" )
    result = pio.read
    pio.close
    raise "error decrypting file #{src}: #{result}" if result.strip != ''
  end

  #----------------------------------------------------------------------------#

  ##
  # Decrypt _ciphertext_ using _key_ and _iv_ using AES-128-CBC.
  #
  def Crypto.decryptsym(plaintext, key, iv)
    raise ArgumentError.new("plaintext must be a String") unless plaintext.is_a? String
    raise ArgumentError.new("invalid key") unless key.is_a? String and key.size == 16
    raise ArgumentError.new("invalid iv") unless iv.is_a? String and iv.size == 16
    
    cipher = OpenSSL::Cipher::Cipher.new( 'AES-128-CBC' )
    cipher.decrypt( key, iv )
    # NOTE: If the key and iv aren't set this doesn't work correctly.
    cipher.key = key
    cipher.iv = iv
    plaintext = cipher.update( plaintext )
    plaintext + cipher.final
  end

  #----------------------------------------------------------------------------#

  ##
  # Generate and return a message digest for the data from the IO stream
  # ((|io|)), using the algorithm alg
  #
  def Crypto.digest(io, alg = OpenSSL::Digest::SHA1.new)
    raise ArgumentError.new('io') unless io.kind_of?(IO) or io.kind_of?(StringIO)
    while not io.eof?
      alg.update(io.read(BUFFER_SIZE))
    end
    alg.digest
  end
  
  #----------------------------------------------------------------------------#
  
  # Return the HMAC SHA1 of _data_ using _key_.
  def Crypto.hmac_sha1( key, data )
    raise ParameterError.new( "key must be a String" ) unless key.is_a? String
    raise ParameterError.new( "data must be a String" ) unless data.is_a? String
    
    md = OpenSSL::Digest::SHA1.new
    hmac = OpenSSL::HMAC.new( key, md)
    hmac.update( data )
    return hmac.digest
  end
  
  #----------------------------------------------------------------------------#
  
  ##
  # Decrypt the specified cipher text file to create the specified plain text
  # file.
  #
  # The symmetric cipher is AES in CBC mode. 128 bit keys are used. If the plain
  # text file already exists it will be overwritten.
  #
  # ((|key|)) The 128 bit (16 byte) symmetric key.
  # ((|src|)) The name of the cipher text file to encrypt.
  # ((|dst|)) The name of the plain text file to create.
  # ((|iv|)) The 128 bit (16 byte) initialization vector.
  #
  def Crypto.encryptfile(src, dst, key, iv)
    raise ArgumentError.new("invalid file name: #{src}") unless FileTest.exists?(src)
    raise ArgumentError.new("invalid key") unless key and key.size == 16
    raise ArgumentError.new("invalid iv") unless iv and iv.size == 16
    cmd = "openssl enc -e -aes-128-cbc -in #{src} -out #{dst} -K #{Format::bin2hex(key)} -iv #{Format::bin2hex(iv)}"
    result = Kernel::system(cmd)
    raise "error encrypting file #{src}" unless result
  end

  #----------------------------------------------------------------------------#

  ##
  # Encrypt _plaintext_ with _key_ and _iv_ using AES-128-CBC.
  #
  def Crypto.encryptsym(plaintext, key, iv)
    raise ArgumentError.new("plaintext must be a String") unless plaintext.kind_of? String
    raise ArgumentError.new("invalid key") unless ( key.is_a? String and key.size == 16 )
    raise ArgumentError.new("invalid iv") unless ( iv.is_a? String and iv.size == 16 )
    
    cipher = OpenSSL::Cipher::Cipher.new( 'AES-128-CBC' )
    cipher.encrypt( key, iv )
    # NOTE: If the key and iv aren't set this doesn't work correctly.
    cipher.key = key
    cipher.iv = iv
    ciphertext = cipher.update( plaintext )
    ciphertext + cipher.final
  end

  #----------------------------------------------------------------------------#

  ##
  # Generate an initialization vector suitable use with symmetric cipher.
  #
  def Crypto.geniv
    OpenSSL::Cipher::Cipher.new(SYM_ALG).random_iv
  end

  #----------------------------------------------------------------------------#

  ##  
  # Generate a key suitable for use with a symmetric cipher.
  #
  def Crypto.gensymkey
    OpenSSL::Cipher::Cipher.new(SYM_ALG).random_key
  end

  #----------------------------------------------------------------------------#

  ##
  # Return the public key from the X509 certificate file ((|filename|)). 
  #
  def Crypto.certfile2pubkey(filename)
    begin
      File.open(filename) do |f|
        return cert2pubkey(f)
      end
    rescue Exception => e
      raise "error reading certificate file #{filename}: #{e.message}"
    end
  end
  
  #----------------------------------------------------------------------------#
  
  def Crypto.cert2pubkey(data)
    begin
      return OpenSSL::X509::Certificate.new(data).public_key
    rescue Exception => e
      raise "error reading certificate: #{e.message}"
    end
  end
  
  #----------------------------------------------------------------------------#

  ##
  # Sign the data from IO stream or string ((|data|)) using the key in
  # ((|keyfilename|)).
  #
  # Return the signature.
  #
  def Crypto.sign(data, keyfilename)
    raise ArgumentError.new('data') unless data
    raise ArgumentError.new("invalid file name: #{keyfilename}") unless FileTest.exists?(keyfilename)

    # Create an IO stream from the data if necessary.
    io = (data.instance_of?(StringIO) ? data : StringIO.new(data))

    sha = OpenSSL::Digest::SHA1.new
    pk  = loadprivkey( keyfilename )
    return pk.sign(sha, io.read )
  end

  #------------------------------------------------------------------------------#

  ##
  # Generate the SHA1 fingerprint for a PEM-encoded certificate (NOT private key)
  # Returns the fingerprint in aa:bb:... form
  # Raises ArgumentError if the fingerprint cannot be obtained
  #
  def Crypto.cert_sha1_fingerprint(cert_filename)
    raise ArgumentError.new('cert_filename is nil')  if cert_filename.nil?
    raise ArgumentError.new("invalid cert file name: #{cert_filename}") unless FileTest.exists?(cert_filename)
    fingerprint = nil
    
    IO.popen("openssl x509 -in #{cert_filename} -noout -sha1 -fingerprint") do |io|
      out = io.read
      md = SHA1_FINGERPRINT_REGEX.match(out)
      if md
        fingerprint = md[1]
      end
    end
  
    raise ArgumentError.new("could not generate fingerprint for #{cert_filename}")  if fingerprint.nil?
    
    return fingerprint    
  end
  
  #------------------------------------------------------------------------------#
  
  def Crypto.loadprivkey filename
    begin
      OpenSSL::PKey::RSA.new( File.open( filename,'r' ) )
    rescue Exception => e
      raise "error reading private key from file #{filename}: #{e.message}"
    end
  end
  
  #----------------------------------------------------------------------------#
  
  ##
  # XOR the byte string ((|a|)) with the byte string ((|b|)). The operans must
  # be of the same length.
  #
  def Crypto.xor(a, b)
    raise ArgumentError.new('data lengths differ') unless a.size == b.size
    xored = String.new
    a.size.times do |i|
      xored << (a[i] ^ b[i])
    end
    
    xored
  end
end
