diff options
| author | Randy Morgan <[email protected]> | 2012-01-06 20:17:32 +0900 |
|---|---|---|
| committer | Randy Morgan <[email protected]> | 2012-01-06 20:17:32 +0900 |
| commit | 20379c963661b8d2cfa79a194bb73bd620e5d1e7 (patch) | |
| tree | 60a083728651c33b6da5f4f53ae89aca358a3f5d /lib | |
| parent | 92cca55f4e23c7dedb71035a9711159773a51ed4 (diff) | |
| download | caxlsx-20379c963661b8d2cfa79a194bb73bd620e5d1e7.tar.gz caxlsx-20379c963661b8d2cfa79a194bb73bd620e5d1e7.zip | |
beginnings of password protected compound binary file using ECMA encryption
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/axlsx.rb | 3 | ||||
| -rw-r--r-- | lib/axlsx/package.rb | 6 | ||||
| -rw-r--r-- | lib/axlsx/util/cbf.rb | 252 | ||||
| -rw-r--r-- | lib/axlsx/util/ms_off_crypto.rb | 181 | ||||
| -rw-r--r-- | lib/axlsx/util/storage.rb | 146 |
5 files changed, 547 insertions, 41 deletions
diff --git a/lib/axlsx.rb b/lib/axlsx.rb index 2f347d49..d60a4aa8 100644 --- a/lib/axlsx.rb +++ b/lib/axlsx.rb @@ -6,7 +6,8 @@ require 'axlsx/version.rb' require 'axlsx/util/simple_typed_list.rb' require 'axlsx/util/constants.rb' require 'axlsx/util/validators.rb' - +require 'axlsx/util/storage.rb' +require 'axlsx/util/cbf.rb' require 'axlsx/util/ms_off_crypto.rb' diff --git a/lib/axlsx/package.rb b/lib/axlsx/package.rb index 1af5761b..8d124ba3 100644 --- a/lib/axlsx/package.rb +++ b/lib/axlsx/package.rb @@ -4,6 +4,7 @@ module Axlsx # xlsx document including valdation and serialization. class Package + # plain text password # provides access to the app doc properties for this package # see App attr_reader :app @@ -87,6 +88,11 @@ module Axlsx true end + # Encrypt the package into a CFB using the password provided + def encrypt(file_name, password) + moc = MsOffCrypto.new(file_name, password) + moc.save + end # Validate all parts of the package against xsd schema. # @return [Array] An array of all validation errors found. diff --git a/lib/axlsx/util/cbf.rb b/lib/axlsx/util/cbf.rb new file mode 100644 index 00000000..b1725457 --- /dev/null +++ b/lib/axlsx/util/cbf.rb @@ -0,0 +1,252 @@ +module Axlsx + + # The Cfb class is a MS-OFF-CRYPTOGRAPHY specific OLE (MS-CBF) writer implementation. No attempt is made to re-invent the wheel for read/write of compound binary files. + class Cbf + + # the serialization for the CBF FAT + FAT_PACKING = "s128" + + # the serialization for the MS-OFF-CRYPTO version stream + VERSION_PACKING = 'l s30 l3' + + # The serialization for the MS-OFF-CRYPTO dataspace map stream + DATA_SPACE_MAP_PACKING = 'l6 s16 l s25' + + # The serialization for the MS-OFF-CRYPTO strong encrytion data space stream + STRONG_ENCRYPTION_DATA_SPACE_PACKING = 'l3 s25' + + # The serialization for the MS-OFF-CRYPTO primary stream + PRIMARY_PACKING = 'l3 s38 l s39 l3 x12 l x2' + + # The cutoff size that determines if a stream should be in the mini-fat or the fat + MINI_CUTOFF = 4096 + + # The serialization for CBF header + HEADER_PACKING = "q x16 l s3 x10 l l x4 l*" + + # Creates a new Cbf object based on the ms_off_crypto object provided. + # @param [MsOffCrypto] ms_off_crypto + def initialize(ms_off_crypto) + @file_name = ms_off_crypto.file_name + create_storages + mini_fat_stream + mini_fat + fat + header + end + + # creates or returns the version storage + # @return [Storage] + def version + @version ||= create_version + end + + # returns the data space map storage + # @return [Storage] + def data_space_map + @data_space_map ||= create_data_space_map + end + + # returns the primary storage + # @return [Storgae] + def primary + @primary ||= create_primary + end + + # returns the stream of data allocated in the fat + # @return [String] + def fat_stream + @fat_stream ||= create_fat_stream + end + + # returns the stream allocated in the mini fat. + # return [String] + def mini_fat_stream + @mini_fat_stream ||= create_mini_fat_stream + end + + # returns the mini fat + # return [String] + def mini_fat + @mini_fat ||= create_mini_fat + end + + # returns the fat + # @return [String] + def fat + @fat ||= create_fat + end + + # returns the CFB header + # @return [String] + def header + @header ||= create_header + end + + + # writes the compound binary file to disk + def save + ole = File.open(@file_name, 'w') + ole << header + ole << fat + @storages.each { |s| ole << s.to_s } + ole << Array.new((512-(ole.pos % 512)), 0).pack('c*') + ole << mini_fat + ole << mini_fat_stream + ole << fat_stream + ole.close + end + + private + + # Generates the storages required for ms-office-cryptography cfb + def create_storages + @storages = [] + @encryption_info = ms_off_crypto.encryption_info + @encrypted_package = ms_off_crypto.encrypted_package + @storages << Storage.new('R', :type=>Storage::TYPES[:root], :color=>Storage::COLORS[:red], :child=>1, :modified=>129685612742510730) + @storages.last.name_size = 2 + @storages << Storage.new('EncryptionInfo', :data=>@encryption_info, :left=>3, :size => @encryption_info.size) # example shows right child. do we need the summary info???? + @storages << Storage.new('EncryptedPackage', :data=>@encrypted_package, :color=>Storage::COLORS[:red], :size=>@encrypted_package.size) + @storages << Storage.new([6].pack("c")+"DataSpaces", :child=>5, :modified =>129685612740945580, :created=>129685612740819979) + @storages << version + @storages << data_space_map + @storages << Storage.new('DataSpaceInfo', :right=>8, :child=>7, :created=>129685612740828880,:modified=>129685612740831800) + @storages << strong_encryption_data_space + @storages << Storage.new('TransformInfo', :color => Storage::COLORS[:red], :child=>9, :created=>129685612740834130, :modified=>129685612740943959) + @storages << Storage.new('StrongEncryptionTransform', :child=>10, :created=>129685612740834169, :modified=>129685612740942280) + @storages << primary + end + + # generates the mini fat stream + # @return [String] + def create_mini_fat_stream + mfs = [] + @storages.select{ |s| s.type == Storage::TYPES[:stream] && s.size < MINI_CUTOFF}.each_with_index do |stream, index| + mfs.concat stream.data + mfs.concat Array.new(64 - (mfs.size % 64), 0) if mfs.size % 64 + end + @storages[0].size = mfs.size + mfs.concat(Array.new(512 - (mfs.size % 512), 0)) + mfs.pack 'c*' + end + + # generates the fat stream. + # @return [String] + def create_fat_stream + mfs = [] + @storages.select{ |s| s.type == Storage::TYPES[:stream] && s.size >= MINI_CUTOFF}.each_with_index do |stream, index| + mfs.concat stream.data + mfs.concat Array.new(512 - (mfs.size % 512), 0) if mfs.size % 512 + end + mfs.pack 'c*' + end + + # creates the mini fat + # @return [String] + def create_mini_fat + v_mf = [] + @storages.select{ |s| s.type == Storage::TYPES[:stream] && s.size < MINI_CUTOFF}.each do |stream| + allocate_stream(v_mf, stream, 64) + end + v_mf.concat Array.new(128 - v_mf.size, -1) + v_mf.pack 'l*' + end + + # creates the fat + # @return [String] + def create_fat + v_fat = [-3] + # storages four per sector, allocation forces directories to start at sector ID 0 + allocate_stream(v_fat, @storages, 4) + # fat entry for minifat + allocate_stream(v_fat, 0, 512) + # fat entry for minifat stream + @storages[0].sector = v_fat.size + allocate_stream(v_fat, mini_fat_stream, 512) + # fat entries for encrypted package storage + # what to do about DIFAT for larger packages... + if @encrypted_package.size > (109 - v_fat.size) * 512 + raise ArgumentError, "Your package is too big!" + end + + if @encrypted_package.size >= MINI_CUTOFF + allocate_stream(v_fat, @encrypted_package, 512) + end + + v_fat.concat Array.new(128 - v_fat.size, -1) if v_fat.size < 128 #pack in unused sectors + v_fat.pack 'l*' + end + + # Creates the version storage + # @return [Storage] + def create_version + v_stream= [60, "Microsoft.Container.DataSpaces".bytes.to_a, 1, 1, 1].flatten!.pack VERSION_PACKING + Storage.new('Version', :data=>v_stream, :size=>v_stream.size) + end + + # returns the strong encryption data space storage + # @return [Storgae] + def strong_encryption_data_space + @strong_encryption_data_space ||= create_strong_encryption_data_space + end + + # Creates the data space map storage + # @return [Storgae] + def create_data_space_map + v_stream = [8,1,104, 1,0, 32, "EncryptedPackage".bytes.to_a, 50, "StrongEncryptionDataSpace".bytes.to_a].flatten!.pack DATA_SPACE_MAP_PACKING + Storage.new('DataSpaceMap', :data=>v_stream, :left => 4, :right => 6, :size=>v_stream.size) + end + + + # creates the stron encryption data space storage + # @return [Storgae] + def create_strong_encryption_data_space + v_stream = [8,1,50,"StrongEncryptionTransform".bytes.to_a].flatten.pack STRONG_ENCRYPTION_DATA_SPACE_PACKING + Storage.new("StrongEncryptionDataSpace", :data=>v_stream, :size => v_stream.size) + end + + # creates the primary storage + # @return [Storgae] + def create_primary + v_stream = [88,1,76,"{FF9A3F03-56EF-4613-BDD5-5A41C1D07246}".bytes.to_a].flatten + v_stream.concat [78, "Microsoft.Container.EncryptionTransform".bytes.to_a,1,1,1,4].flatten + v_stream = v_stream.pack PRIMARY_PACKING + Storage.new([6].pack("c")+"Primary", :data=>v_stream, :size=>v_stream.size) + end + + # Creates the header + # @return [String] + def create_header + header = [] + header << -2226271756974174256 # identifier pack as q + header << 196670 # version pack as L + header << 65534 # byte order pack as s + header << 9 # sector shift + header << 6 # mini-sector shift + header << (fat.size/512.0).ceil # this is the number of FAT sectors in the file at index 6 pack as L + header << header.last # this is the first directory sector, index of 7 pack as L + header << MINI_CUTOFF # minfat cutoff pack as L + # MiniFat starts after directories + header << (fat.size/512.0).ceil + (@storages.size/4.0).ceil # this is the sector id for the first minifat index 10 pack as L + header << (mini_fat.size/512.0).ceil # minifat sector count index 11 pack as L + header << -2 # the first DIFAT - set to end of chain until we exceed a single FAT pack as L + header << 0 # number of DIFAT sectors, unless we go beyond 109 FAT sectors this will always be 0 pack as L + header << 0 # first FAT sector defined in the DIFAT pack as L + header.concat Array.new(108, -1) # Difat sectors pack as L108 + header.pack(HEADER_PACKING) + end + + # Allocates sector chains in a allocation table based on the sector size and stream provided + # If a storage obeject is provided, the starting sector value for the storage is updated based on the allocation performed here. + # @param [Array] table Allocation table array + # @param [Storage | String] stream + # @param [Integer] size The cutoff size for the stream. + def allocate_stream(table, stream, size) + stream.sector = table.size if stream.respond_to?(:sector) + ((stream.size / size.to_f).ceil).times { table << table.size } + table[table.size-1] = -2 # this is the CBF chain terminator + end + + end +end diff --git a/lib/axlsx/util/ms_off_crypto.rb b/lib/axlsx/util/ms_off_crypto.rb index 7f20ac28..486f8d70 100644 --- a/lib/axlsx/util/ms_off_crypto.rb +++ b/lib/axlsx/util/ms_off_crypto.rb @@ -1,61 +1,152 @@ +# -*- coding: utf-8 -*- require 'digest' require 'base64' require 'openssl' + module Axlsx + + # The MsOffCrypto class implements ECMA-367 encryption based on the MS-OFF-CRYPTO specification class MsOffCrypto - attr_reader :verifier - attr_reader :key + # Creates a new MsOffCrypto Object + # @param [String] file_name the location of the file you want to encrypt + # @param [String] pwd the password to use when encrypting the file + def initialize(file_name, pwd) + self.password = pwd + self.file_name = file_name + end + + # Generates a new CBF file based on this instance of ms-off-crypto and overwrites the unencrypted file. + def save + cfb = Cbf.new(self) + cfb.save + end - def initialize(password = "passowrd") - @password = password - @salt_size = 0x10 - @key_size = 0x100 - @verifier = rand(16**16).to_s + # returns the raw password used in encryption + # @return [String] + attr_reader :password - #fixed salt for testing - @salt = [0x90,0xAC,0x68,0x0E,0x76,0xF9,0x43,0x2B,0x8D,0x13,0xB7,0x1D,0xB7,0xC0,0xFC,0x0D].join - # @salt =Digest::SHA1.digest(rand(16**16).to_s) + # sets the password to be used for encryption + # @param [String] v the password, @default 'password' + # @return [String] + def password=(v) + @password = v || 'password' end - def encryption_info - # v.major v.minor flags header length flags size # AES 128 bit - header = [3, 0, 2, 0, 0x24, 0, 0, 0, 0xA4, 0, 0, 0, 0x24, 0, 0, 0, 0, 0, 0, 0, 0x0E, 0x66, 0, 0] - header.concat [0x04, 0x80, 0, 0, 0x80, 0, 0, 0, 0x18, 0, 0, 0, 0xA0, 0xC7, 0xDC, 0x2, 0, 0, 0, 0] - header.concat "Microsoft Enhanced RSA and AES Cryptographic Provider (Prototype)".bytes.to_a.pack('s*').bytes.to_a - header.concat [0, 0] - header.concat [0x10, 0, 0, 0] - header.concat [0x90,0xAC,0x68,0x0E,0x76,0xF9,0x43,0x2B,0x8D,0x13,0xB7,0x1D,0xB7,0xC0,0xFC,0x0D] - header.concat encrypted_verifier.bytes.to_a.pack('c*').bytes.to_a - header.concat [20, 0,0,0] - header.concat encrypted_verifier_hash.bytes.to_a.pack('c*').bytes.to_a - header.flatten! - header.pack('c*') + # retruns the file name of the archive to be encrypted + # @return [String] + attr_reader :file_name + + # sets the filename + # @return [String] + def file_name=(v) + #TODO verfify that the file specified exists and is an unencrypted xlsx archive + @file_name = v end - def encryption_verifier - {:salt_size => @salt_size, - :salt => @salt, - :encrypted_verifier => encrypted_verifier, - :varifier_hash_size => 0x14, - :encrypted_verifier_hash => encrypted_verifier_hash} + + # encrypts and returns the package specified by the file name + # @return [String] + def encrypted_package + @encrypted_package ||= encrypt_package(file_name, password) end - # 2.3.3 + # returns the encryption info for this instance of ms-off-crypto + # @return [String] + def encryption_info + @encryption_info ||= create_encryption_info + end + + # returns a random salt + # @return [String] + def salt + @salt ||= Digest::SHA1.digest(rand(16**16).to_s) + end + + # returns a random verifier + # @return [String] + def verifier + @verifier ||= rand(16**16).to_s + end + + # returns the verifier encrytped + # @return [String] def encrypted_verifier - @encrypted_verifier ||= encrypt(@verifier) + @encrypted_verifier ||= encrypt(verifier) end - # 2.3.3 + # returns the verifier hash encrypted + # @return [String] def encrypted_verifier_hash - verifier_hash = Digest::SHA1.digest(@verifier) - verifier_hash << Array.new(32 - verifier_hash.size, 0).join('') @encrypted_verifier_hash ||= encrypt(verifier_hash) end + # returns a verifier hash + # @return [String] + def verifier_hash + @verifier_hash ||= create_verifier_hash + end + + # returns an encryption key + # @return [String] + def key + @key ||= create_key + end + + # size of unencrypted package? concated with encrypted package + def encrypt_package(file_name, password) + package = File.open(file_name, 'r') + package_text = package.read + [package_text.bytes.to_a.size].pack('q') + encrypt(package_text) + end + + # Generates an encryption info structure + # @return [String] + def create_encryption_info + header = [3, 0, 2, 0] # version + # Header flags copy + header.concat [0x24, 0, 0, 0] #flags -- VERY UNSURE ABOUT THIS STILL + header.concat [0, 0, 0, 0] #unused + header.concat [0xA4, 0, 0, 0] #length + # Header + header.concat [0x24, 0, 0, 0] #flags again + header.concat [0, 0, 0, 0] #unused again, + header.concat [0x0E, 0x66, 0, 0] #alg id + header.concat [0x04, 0x80, 0, 0] #alg hash id + header.concat [key.size, 0, 0, 0] #key size + header.concat [0x18, 0, 0, 0] #provider type + header.concat [0, 0, 0, 0] #reserved 1 + header.concat [0, 0, 0, 0] #reserved 2 + #header.concat [0xA0, 0xC7, 0xDC, 0x2, 0, 0, 0, 0] + header.concat "Microsoft Enhanced RSA and AES Cryptographic Provider (Prototype)".bytes.to_a.pack('s*').bytes.to_a + header.concat [0, 0] #null terminator + + #Salt Size + header.concat [salt.bytes.to_a.size].pack('l').bytes.to_a + #Salt + header.concat salt.bytes.to_a.pack('c*').bytes.to_a + # encryption verifier + header.concat encrypted_verifier.bytes.to_a.pack('c*').bytes.to_a + + # verifier hash size -- MUST BE 32 bytes + header.concat [verifier_hash.bytes.to_a.size].pack('l').bytes.to_a + + #encryption verifier hash + header.concat encrypted_verifier_hash.bytes.to_a.pack('c*').bytes.to_a + + header.flatten! + header.pack('c*') + end + + # 2.3.3 + def create_verifier_hash + vh = Digest::SHA1.digest(verifier) + vh << Array.new(32 - vh.size, 0).join('') + end + # 2.3.4.7 ECMA-376 Document Encryption Key Generation (Standard Encryption) - def key - sha = Digest::SHA1.new() << (@salt + @password) + def create_key + sha = Digest::SHA1.new() << (salt + @password) (0..49999).each { |i| sha.update(i.to_s+sha.to_s) } key = sha.update(sha.to_s+'0').digest a = key.bytes.each_with_index.map { |item, i| 0x36 ^ item } @@ -63,25 +154,35 @@ module Axlsx a = key.bytes.each_with_index.map { |item, i| 0x5C ^ item } x2 = Digest::SHA1.digest( (a.concat Array.new(64 - key.size, 0x5C) ).to_s) x3 = x1 + x2 - @key ||= x3.bytes.to_a[(0..31)].pack('c*') + x3.bytes.to_a[(0..31)].pack('c*') end + # ensures that the a hashed decryption of the encryption verifier matches the decrypted verifier hash. + # @return [Boolean] def verify_password - puts decrypt(@encrypted_verifier) + v = Digest::SHA1.digest decrypt(@encrypted_verifier) + vh = decrypt(@encrypted_verifier_hash) + vh[0..15] == v[0..15] end + # encrypts the data proved + # @param [String] data + # @return [String] the encrypted data def encrypt(data) aes = OpenSSL::Cipher.new("AES-128-ECB") aes.encrypt aes.key = key - aes.update(data) + aes.update(data) << aes.final end + # dencrypts the data proved + # @param [String] data + # @return [String] the dencrypted data def decrypt(data) aes = OpenSSL::Cipher.new("AES-128-ECB") aes.decrypt aes.key = key - aes.update(data) + aes.update(data) << aes.final end end diff --git a/lib/axlsx/util/storage.rb b/lib/axlsx/util/storage.rb new file mode 100644 index 00000000..6841d3d7 --- /dev/null +++ b/lib/axlsx/util/storage.rb @@ -0,0 +1,146 @@ +module Axlsx + + # The Storage class represents a storage object or stream in a compound file. + class Storage + + # Packing for the Storage when pushing an array of items into a byte stream + # Name, name length, type, color, left sibling, right sibling, child, classid, state, created, modified, sector, size + PACKING = "s32 s1 c2 l3 x16 x4 q2 l q" + + # storage types + TYPES = { + :root=>5, + :stream=>2, + :storage=>1 + } + + # Creates a byte string for this storage + # @return [String] + def to_s + data = [@name.concat(Array.new([email protected], 0)), + @name_size, + @type, + @color, + @left, + @right, + @child, + @created, + @modified, + @sector, + @size].flatten + puts data.inspect + data.pack(PACKING) + end + + # storage colors + COLORS = { + :red=>0, + :black=>1 + } + + # The color of this node in the directory tree. Defaults to black if not specified + # @return [Integer] color + attr_reader :color + + # Sets the color for this storage + # @param [Integer] v Must be one of the COLORS constant hash values + def color=(v) + RestrictionValidator.validate "Storage.color", COLORS.values, v + @color = v + end + + # The size of the name for this node. + # interesting to see that office actually uses 'R' for the root directory and lists the size as 2 bytes - thus is it *NOT* null + # terminated. I am making this r/w so that I can override the size + # @return [Integer] color + attr_reader :name_size + + # the name of the stream + attr_reader :name + + # sets the name of the stream. + # This will automatically set the name_size attribute + # @return [String] name + def name=(v) + @name = v.bytes.to_a << 0 + @name_size = @name.size * 2 + @name + end + + # The size of the stream + attr_reader :size + + # The stream associated with this storage + attr_reader :data + + # Set the data associated with the stream. If the stream type is undefined, we automatically specify the storage as a stream type. # with the exception of storages that are type root, all storages with data should be type stream. + # @param [String] v The data for this storages stream + # @return [Array] + def data=(v) + Axlsx::validate_string(v) + self.type = TYPES[:stream] unless @type + @size = v.size + @data = v.bytes.to_a + end + + # The starting sector for the stream. If this storage is not a stream, or the root node this is nil + # @return [Integer] sector + attr_accessor :sector + + # The 0 based index in the directoies chain for this the left sibling of this storage. + + # @return [Integer] left + attr_accessor :left + + # The 0 based index in the directoies chain for this the right sibling of this storage. + # @return [Integer] right + attr_accessor :right + + # The 0 based index in the directoies chain for the child of this storage. + # @return [Integer] child + attr_accessor :child + + # The created attribute for the storage + # @return [Integer] created + attr_accessor :created + + # The modified attribute for the storage + # @return [Integer] modified + attr_accessor :modified + + # The type of storage + # see TYPES + # @return [Integer] type + attr_reader :type + + # Sets the type for this storage. + # @param [Integer] v the type to specify must be one of the TYPES constant hash values. + def type=(v) + RestrictionValidator.validate "Storage.type", TYPES.values, v + @type = v + end + + # Creates a new storage object. + # @param [String] name the name of the storage + # @option options [Integer] color @default black + # @option options [Integer] type @default storage + # @option options [String] data + # @option options [Integer] left @default -1 + # @option options [Integer] right @default -1 + # @option options [Integer] child @default -1 + # @option options [Integer] created @default 0 + # @option options [Integer] modified @default 0 + # @option options [Integer] sector @default 0 + def initialize(name, options= {}) + @left = @right = @child = -1 + @sector = @size = @created = @modified = 0 + options.each do |o| + self.send("#{o[0]}=", o[1]) if self.respond_to? "#{o[0]}=" + end + @color ||= COLORS[:black] + @type ||= (data.nil? ? TYPES[:storage] : TYPES[:stream]) + self.name = name + end + + end +end |
