# coding: utf-8
# typed: strict

require 'digest/md5'
require 'rc4'

class PDF::Reader

  # Processes the Encrypt dict from an encrypted PDF and a user provided
  # password and returns a key that can decrypt the file.
  #
  # This can generate a key compatible with the following standard encryption algorithms:
  #
  # * Version 1-3, all variants
  # * Version 4, V2 (RC4) and AESV2
  #
  class StandardKeyBuilder

    ## 7.6.3.3 Encryption Key Algorithm (pp61)
    #
    # needs a document's user password to build a key for decrypting an
    # encrypted PDF document
    #
    PassPadBytes = [ 0x28, 0xbf, 0x4e, 0x5e, 0x4e, 0x75, 0x8a, 0x41,
                     0x64, 0x00, 0x4e, 0x56, 0xff, 0xfa, 0x01, 0x08,
                     0x2e, 0x2e, 0x00, 0xb6, 0xd0, 0x68, 0x3e, 0x80,
                     0x2f, 0x0c, 0xa9, 0xfe, 0x64, 0x53, 0x69, 0x7a ] #: Array[Integer]

    #: (?Hash[Symbol, untyped]) -> void
    def initialize(opts = {})
      @key_length    = opts[:key_length].to_i/8 #: Integer
      @revision      = opts[:revision].to_i #: Integer
      @owner_key     = opts[:owner_key] #: String?
      @user_key      = opts[:user_key] #: String?
      @permissions   = opts[:permissions].to_i #: Integer
      @encryptMeta   = opts.fetch(:encrypted_metadata, true) #: bool
      @file_id       = opts[:file_id] || "" #: String

      if @key_length != 5 && @key_length != 16
        msg = "StandardKeyBuilder only supports 40 and 128 bit\
               encryption (#{@key_length * 8}bit)"
        raise UnsupportedFeatureError, msg
      end
    end

    # Takes a string containing a user provided password.
    #
    # If the password matches the file, then a string containing a key suitable for
    # decrypting the file will be returned. If the password doesn't match the file,
    # and exception will be raised.
    #
    #: (?String) -> String
    def key(pass = "")
      encrypt_key   = auth_owner_pass(pass)
      encrypt_key ||= auth_user_pass(pass)

      raise PDF::Reader::EncryptedPDFError, "Invalid password (#{pass})" if encrypt_key.nil?
      encrypt_key
    end

    private

    # Pads supplied password to 32bytes using PassPadBytes as specified on
    # pp61 of spec
    #
    #: (?String?) -> String
    def pad_pass(p="")
      return PassPadBytes.pack('C*') if p.nil? || p.empty?

      pTrimmedLength = [p.bytesize, 32].min
      pad = PassPadBytes[0, 32 - pTrimmedLength] || []
      p[0, 32].to_s + pad.pack('C*')
    end

    #: (String, Integer) -> String
    def xor_each_byte(buf, int)
      buf.each_byte.map{ |b| b^int}.pack("C*")
    end

    ## 7.6.3.4 Password Algorithms
    #
    # Algorithm 7 - Authenticating the Owner Password
    #
    # Used to test Owner passwords
    #
    # if the string is a valid owner password this will return the user
    # password that should be used to decrypt the document.
    #
    # if the supplied password is not a valid owner password for this document
    # then it returns nil
    #
    #: (String) -> String?
    def auth_owner_pass(pass)
      md5 = Digest::MD5.digest(pad_pass(pass))
      if @revision > 2 then
        50.times { md5 = Digest::MD5.digest(md5) }
        keyBegins = md5[0, @key_length]
        #first iteration decrypt owner_key
        out = @owner_key
        #RC4 keyed with (keyBegins XOR with iteration #) to decrypt previous out
        19.downto(0).each { |i|
          # The RC4 gem doen't have type annotations, so the type checker doesn't
          # know decrypt() returns a string
          out = TypeCheck.cast_to_string!(
            RC4.new(xor_each_byte(keyBegins,i)).decrypt(out)
          )
        }
      else
        out = RC4.new( md5[0, 5] ).decrypt( @owner_key )
      end
      # c) check output as user password
      auth_user_pass( out )
    end

    # Algorithm 6 - Authenticating the User Password
    #
    # Used to test User passwords
    #
    # if the string is a valid user password this will return the user
    # password that should be used to decrypt the document.
    #
    # if the supplied password is not a valid user password for this document
    # then it returns nil
    #
    #: (String) -> String?
    def auth_user_pass(pass)
      keyBegins = make_file_key(pass)
      if @revision >= 3
        #initialize out for first iteration
        out = Digest::MD5.digest(PassPadBytes.pack("C*") + @file_id)
        #zero doesn't matter -> so from 0-19
        20.times{ |i| out=RC4.new(xor_each_byte(keyBegins, i)).encrypt(out) }
        pass = @user_key.to_s[0, 16] == out
      else
        pass = RC4.new(keyBegins).encrypt(PassPadBytes.pack("C*")) == @user_key
      end
      pass ? keyBegins : nil
    end

    #: (String) -> String
    def make_file_key( user_pass )
      # a) if there's a password, pad it to 32 bytes, else, just use the padding.
      buf  = pad_pass(user_pass)
      # c) add owner key
      buf << @owner_key
      # d) add permissions 1 byte at a time, in little-endian order
      (0..24).step(8){|e| buf << (@permissions >> e & 0xFF)}
      # e) add the file ID
      buf << @file_id
      # f) if revision >= 4 and metadata not encrypted then add 4 bytes of 0xFF
      if @revision >= 4 && !@encryptMeta
        buf << [0xFF,0xFF,0xFF,0xFF].pack('C*')
      end
      # b) init MD5 digest + g) finish the hash
      md5 = Digest::MD5.digest(buf)
      # h) spin hash 50 times
      if @revision >= 3
        50.times {
          md5 = Digest::MD5.digest(md5[0, @key_length])
        }
      end
      # i) n = key_length revision >= 3, n = 5 revision == 2
      if @revision < 3
        md5[0, 5]
      else
        md5[0, @key_length]
      end
    end

  end
end
