# coding: utf-8
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 ]
def initialize(opts = {})
@key_length = opts[:key_length].to_i/8
@revision = opts[:revision].to_i
@owner_key = opts[:owner_key]
@user_key = opts[:user_key]
@permissions = opts[:permissions].to_i
@encryptMeta = opts.fetch(:encrypted_metadata, true)
@file_id = opts[:file_id] || ""
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.
#
def key(pass)
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
def pad_pass(p="")
if p.nil? || p.empty?
PassPadBytes.pack('C*')
else
p[0, 32] + PassPadBytes[0, 32-p.length].pack('C*')
end
end
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
#
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| out=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
#
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[0, 16] == out
else
pass = RC4.new(keyBegins).encrypt(PassPadBytes.pack("C*")) == @user_key
end
pass ? keyBegins : nil
end
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