# frozen_string_literal: true
# encryption.rb : Implements encrypted PDF and access permissions.
#
# Copyright August 2008, Brad Ediger. All Rights Reserved.
#
# This is free software. Please see the LICENSE and COPYING files for details.
require 'digest/md5'
require_relative 'security/arcfour'
module Prawn
class Document
# Implements PDF encryption (password protection and permissions) as
# specified in the PDF Reference, version 1.3, section 3.5 "Encryption".
module Security
# @group Experimental API
# Encrypts the document, to protect confidential data or control
# modifications to the document. The encryption algorithm used is
# detailed in the PDF Reference 1.3, section 3.5 "Encryption", and it is
# implemented by all major PDF readers.
#
# +options+ can contain the following:
#
# :user_password:: Password required to open the document. If
# this is omitted or empty, no password will be
# required. The document will still be
# encrypted, but anyone can read it.
#
# :owner_password:: Password required to make modifications to
# the document or change or override its
# permissions. If this is set to
# :random, a random password will be
# used; this can be useful if you never want
# users to be able to override the document
# permissions.
#
# :permissions:: A hash mapping permission symbols (see below) to
# true or false. True means
# "permitted", and false means "not permitted".
# All permissions default to true.
#
# The following permissions can be specified:
#
# :print_document:: Print document.
#
# :modify_contents:: Modify contents of document (other than text
# annotations and interactive form fields).
#
# :copy_contents:: Copy text and graphics from document.
#
# :modify_annotations:: Add or modify text annotations and
# interactive form fields.
#
# == Examples
#
# Deny printing to everyone, but allow anyone to open without a password:
#
# encrypt_document :permissions => { :print_document => false },
# :owner_password => :random
#
# Set a user and owner password on the document, with full permissions for
# both the user and the owner:
#
# encrypt_document :user_password => 'foo', :owner_password => 'bar'
#
# Set no passwords, grant all permissions (This is useful because the
# default in some readers, if no permissions are specified, is "deny"):
#
# encrypt_document
#
# == Caveats
#
# * The encryption used is weak; the key is password-derived and is
# limited to 40 bits, due to US export controls in effect at the time
# the PDF standard was written.
#
# * There is nothing technologically requiring PDF readers to respect the
# permissions embedded in a document. Many PDF readers do not.
#
# * In short, you have no security at all against a moderately
# motivated person. Don't use this for anything super-serious. This is
# not a limitation of Prawn, but is rather a built-in limitation of the
# PDF format.
#
def encrypt_document(options = {})
Prawn.verify_options %i[user_password owner_password permissions],
options
@user_password = options.delete(:user_password) || ''
@owner_password = options.delete(:owner_password) || @user_password
if @owner_password == :random
# Generate a completely ridiculous password
@owner_password = (1..32).map { rand(256) }.pack('c*')
end
self.permissions = options.delete(:permissions) || {}
# Shove the necessary entries in the trailer and enable encryption.
state.trailer[:Encrypt] = encryption_dictionary
state.encrypt = true
state.encryption_key = user_encryption_key
end
# Encrypts the given string under the given key, also requiring the
# object ID and generation number of the reference.
# See Algorithm 3.1.
def self.encrypt_string(str, key, id, gen)
# Convert ID and Gen number into little-endian truncated byte strings
id = [id].pack('V')[0, 3]
gen = [gen].pack('V')[0, 2]
extended_key = "#{key}#{id}#{gen}"
# Compute the RC4 key from the extended key and perform the encryption
rc4_key = Digest::MD5.digest(extended_key)[0, 10]
Arcfour.new(rc4_key).encrypt(str)
end
private
# Provides the values for the trailer encryption dictionary.
def encryption_dictionary
{
Filter: :Standard, # default PDF security handler
V: 1, # "Algorithm 3.1", PDF reference 1.3
R: 2, # Revision 2 of the algorithm
O: PDF::Core::ByteString.new(owner_password_hash),
U: PDF::Core::ByteString.new(user_password_hash),
P: permissions_value
}
end
# Flags in the permissions word, numbered as LSB = 1
PERMISSIONS_BITS = {
print_document: 3,
modify_contents: 4,
copy_contents: 5,
modify_annotations: 6
}.freeze
private_constant :PERMISSIONS_BITS
FULL_PERMISSIONS = 0b1111_1111_1111_1111_1111_1111_1111_1111
private_constant :FULL_PERMISSIONS
def permissions=(perms = {})
@permissions ||= FULL_PERMISSIONS
perms.each do |key, value|
unless PERMISSIONS_BITS[key]
raise(
ArgumentError,
"Unknown permission :#{key}. Valid options: " +
PERMISSIONS_BITS.keys.map(&:inspect).join(', ')
)
end
# 0-based bit number, from LSB
bit_position = PERMISSIONS_BITS[key] - 1
if value # set bit
@permissions |= (1 << bit_position)
else # clear bit
@permissions &= ~(1 << bit_position)
end
end
end
def permissions_value
@permissions || FULL_PERMISSIONS
end
PASSWORD_PADDING =
'28BF4E5E4E758A4164004E56FFFA01082E2E00B6D0683E802F0CA9FE6453697A'
.scan(/../).map { |x| x.to_i(16) }.pack('c*')
# Pads or truncates a password to 32 bytes as per Alg 3.2.
def pad_password(password)
password = password[0, 32]
password + PASSWORD_PADDING[0, 32 - password.length]
end
def user_encryption_key
@user_encryption_key ||= begin
md5 = Digest::MD5.new
md5 << pad_password(@user_password)
md5 << owner_password_hash
md5 << [permissions_value].pack('V')
md5.digest[0, 5]
end
end
# The O (owner) value in the encryption dictionary. Algorithm 3.3.
def owner_password_hash
@owner_password_hash ||= begin
key = Digest::MD5.digest(pad_password(@owner_password))[0, 5]
Arcfour.new(key).encrypt(pad_password(@user_password))
end
end
# The U (user) value in the encryption dictionary. Algorithm 3.4.
def user_password_hash
Arcfour.new(user_encryption_key).encrypt(PASSWORD_PADDING)
end
end
end
end
# @private
module PDF
module Core
module_function
# Like pdf_object, but returns an encrypted result if required.
# For direct objects, requires the object identifier and generation number
# from the indirect object referencing obj.
#
# @private
def encrypted_pdf_object(obj, key, id, gen, in_content_stream = false)
case obj
when Array
'[' + obj.map do |e|
encrypted_pdf_object(e, key, id, gen, in_content_stream)
end.join(' ') + ']'
when LiteralString
obj = ByteString.new(
Prawn::Document::Security.encrypt_string(obj, key, id, gen)
).gsub(/[\\\n\(\)]/) { |m| "\\#{m}" }
"(#{obj})"
when Time
obj = obj.strftime('D:%Y%m%d%H%M%S%z').chop.chop + "'00'"
obj = ByteString.new(
Prawn::Document::Security.encrypt_string(obj, key, id, gen)
).gsub(/[\\\n\(\)]/) { |m| "\\#{m}" }
"(#{obj})"
when String
pdf_object(
ByteString.new(
Prawn::Document::Security.encrypt_string(obj, key, id, gen)
),
in_content_stream
)
when ::Hash
'<< ' +
obj.map do |k, v|
unless k.is_a?(String) || k.is_a?(Symbol)
raise PDF::Core::Errors::FailedObjectConversion,
'A PDF Dictionary must be keyed by names'
end
pdf_object(k.to_sym, in_content_stream) + ' ' +
encrypted_pdf_object(v, key, id, gen, in_content_stream) + "\n"
end.join('') +
'>>'
when NameTree::Value
pdf_object(obj.name) + ' ' +
encrypted_pdf_object(obj.value, key, id, gen, in_content_stream)
when PDF::Core::OutlineRoot, PDF::Core::OutlineItem
encrypted_pdf_object(obj.to_hash, key, id, gen, in_content_stream)
else # delegate back to pdf_object
pdf_object(obj, in_content_stream)
end
end
# @private
class Stream
def encrypted_object(key, id, gen)
if filtered_stream
"stream\n" +
Prawn::Document::Security.encrypt_string(
filtered_stream, key, id, gen
) + "\nendstream\n"
else
''
end
end
end
# @private
class Reference
# Returns the object definition for the object this references, keyed from
# +key+.
def encrypted_object(key)
@on_encode&.call(self)
output = +"#{@identifier} #{gen} obj\n"
if @stream.empty?
output <<
PDF::Core.encrypted_pdf_object(data, key, @identifier, gen) << "\n"
else
output << PDF::Core.encrypted_pdf_object(
data.merge(@stream.data), key, @identifier, gen
) << "\n" <<
@stream.encrypted_object(key, @identifier, gen)
end
output << "endobj\n"
end
end
end
end