158 lines
5.0 KiB
Ruby
158 lines
5.0 KiB
Ruby
# 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
|