Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for reading ZIP files utilising AES encryption #179

Open
wants to merge 17 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/zip.rb
Expand Up @@ -22,6 +22,7 @@
require 'zip/pass_thru_compressor'
require 'zip/pass_thru_decompressor'
require 'zip/inflater'
require 'zip/decrypter'
require 'zip/deflater'
require 'zip/streamable_stream'
require 'zip/streamable_directory'
Expand Down
2 changes: 2 additions & 0 deletions lib/zip/constants.rb
Expand Up @@ -11,6 +11,8 @@ module Zip
VERSION_NEEDED_TO_EXTRACT = 20
VERSION_NEEDED_TO_EXTRACT_ZIP64 = 45

GP_FLAGS_DESCRIPTOR_PRESENT = 0x08

FILE_TYPE_FILE = 010
FILE_TYPE_DIR = 004
FILE_TYPE_SYMLINK = 012
Expand Down
2 changes: 2 additions & 0 deletions lib/zip/decompressor.rb
@@ -1,5 +1,7 @@
module Zip
class Decompressor #:nodoc:all
attr_accessor :input_stream

CHUNK_SIZE = 32768
def initialize(input_stream)
super()
Expand Down
95 changes: 95 additions & 0 deletions lib/zip/decrypter.rb
@@ -0,0 +1,95 @@
require 'openssl'
require 'stringio'

module Zip
class Decrypter < Decompressor #:nodoc:all
VERIFIER_LENGTH = 2
BLOCK_SIZE = 16
AUTHENTICATION_CODE_LENGTH = 10

attr_writer :password

def initialize(input_stream, encryption_strength, entry_size, decompressor)
super(input_stream)

@data_length = entry_size - AUTHENTICATION_CODE_LENGTH
@decompressor = decompressor
@decompressor.input_stream = StringIO.new
@encryption_strength = encryption_strength
@prepared = false
end

def sysread(number_of_bytes = nil, buf = '')
prepare_aes unless @prepared

amount_to_read = @data_length
raise RuntimeError, "Incorrect entry size given, can't proceed" if amount_to_read <= 0

counter = 1
while amount_to_read > 0
set_iv(counter)

encrypted = @input_stream.read([BLOCK_SIZE, amount_to_read].min)
# Add the decrypted data to the IO object the decompressor interacts with
@decompressor.input_stream.write(@cipher.update(encrypted))

amount_to_read -= BLOCK_SIZE
counter += 1
end

# TODO: Check Authentication value
@input_stream.read(AUTHENTICATION_CODE_LENGTH)

@decompressor.input_stream.rewind
@decompressor.sysread
end

def input_finished?
@decompressor.input_finished?
end

alias :eof :input_finished?
alias :eof? :input_finished?

private

def prepare_aes
raise RuntimeError, "No password given" if @password.nil?
n = @encryption_strength + 1

headers = {
bits: 64 * n,
key_length: 8 * n,
mac_length: 8 * n,
salt_length: 4 * n
}

raise RuntimeError, "AES-#{headers[:bits]} is not supported." unless [0x01, 0x02, 0x03].include? @encryption_strength

@cipher = OpenSSL::Cipher::AES.new(headers[:bits], :CTR)
@cipher.decrypt

salt = @input_stream.read(headers[:salt_length])
verification = @input_stream.read(VERIFIER_LENGTH)
# The first few bytes are AES setup. Ensure we don't read beyond the end of the data during sysread
@data_length -= (headers[:salt_length] + VERIFIER_LENGTH)

key = OpenSSL::PKCS5.pbkdf2_hmac_sha1(
@password,
salt,
1000,
headers[:key_length] + headers[:mac_length] + VERIFIER_LENGTH
)

raise RuntimeError, "Incorrect password" unless key[-2..-1] == verification
@cipher.key = key

@prepared = true
end

def set_iv(counter)
# Reverse engineered this value from Zip4j's AES support.
@cipher.iv = [counter].pack("Vx12")
end
end
end
34 changes: 33 additions & 1 deletion lib/zip/entry.rb
Expand Up @@ -2,6 +2,7 @@ module Zip
class Entry
STORED = 0
DEFLATED = 8
ENCRYPTED = 99
# Language encoding flag (EFS) bit
EFS = 0b100000000000

Expand Down Expand Up @@ -141,7 +142,10 @@ def cdir_header_size #:nodoc:all
end

def next_header_offset #:nodoc:all
local_entry_offset + self.compressed_size
# FIXME. the data descriptor can be only 12 bytes long, if the signature isn't present
# I need to add code to deal with this!
# I also have no idea why I have to subtract 8 bytes for AES zips. It works, sooooo...
local_entry_offset + self.compressed_size + (@data_descriptor_present ? 16 : 0) - (@extra.member?('AES') ? 8 : 0)
end

# Extracts entry to file dest_path (defaults to @name).
Expand Down Expand Up @@ -248,6 +252,34 @@ def read_local_entry(io) #:nodoc:all
end
parse_zip64_extra(true)
@local_header_size = calculate_local_header_size

# If the "data descriptor present" bit is set in the general flags
# we need to read the uncompressed size and CRC from the data descriptor
# otherwise they'll remain as 0
@data_descriptor_present = (@gp_flags & ::Zip::GP_FLAGS_DESCRIPTOR_PRESENT != 0)
if @data_descriptor_present
pos = io.tell
# We need to seek forwards until we find the data descriptor signature
# (504B0708) or the next record's local file header signature (504B0304)
# and scan back a few
last_four = []

loop do
last_four.push(io.read(1))
last_four = last_four[-4..-1] if last_four.length > 4

case last_four
when %W{\x50 \x4B \x07 \x08}
break
when %W{\x50 \x4B \x03 \x04}
io.seek(-12,IO::SEEK_CUR )
break
end
end

@crc, @compressed_size, @size = io.read(12).unpack("VVV")
io.seek(pos)
end
end

def pack_local_entry
Expand Down
1 change: 1 addition & 0 deletions lib/zip/extra_field.rb
Expand Up @@ -94,6 +94,7 @@ def local_size
require 'zip/extra_field/unix'
require 'zip/extra_field/zip64'
require 'zip/extra_field/zip64_placeholder'
require 'zip/extra_field/aes'

# Copyright (C) 2002, 2003 Thomas Sondergaard
# rubyzip is free software; you can redistribute it and/or
Expand Down
31 changes: 31 additions & 0 deletions lib/zip/extra_field/aes.rb
@@ -0,0 +1,31 @@
module Zip
# Info-ZIP Extra for AES encryption
class ExtraField::AES < ExtraField::Generic
attr_reader :data_size, :vendor_version, :vendor_id, :encryption_strength, :compression_method
HEADER_ID = "\x01\x99".force_encoding("ASCII-8BIT")
register_map

def initialize(binstr = nil)
@data_size = nil
@vendor_version = nil
@vendor_id = nil
@encryption_strength = nil
@compression_method = nil
binstr and merge(binstr)
end

def merge(binstr)
return if binstr.empty?
_, @data_size, @vendor_version, @vendor_id, @encryption_strength, @compression_method = binstr.to_s.unpack("vvva2Cv")
end

def pack_for_local
return '' unless @data_size && @vendor_version && @vendor_id && @encryption_strength && @compression_method
[0x01, 0x99, @data_size, @vendor_version, @vendor_id, @encryption_strength, @compression_method].pack("vvvvM2Cv")
end

def pack_for_c_dir
pack_for_local
end
end
end
15 changes: 14 additions & 1 deletion lib/zip/file.rb
Expand Up @@ -88,6 +88,10 @@ def initialize(file_name, create = nil, buffer = false, options = {})
@restore_times = options[:restore_times] || true
end

def password=(password)
@password = password
end

class << self
# Same as #new. If a block is passed the ZipFile object is passed
# to the block and is automatically closed afterwards just as with
Expand Down Expand Up @@ -220,7 +224,16 @@ def split(zip_file_name, segment_size = MAX_SEGMENT_SIZE, delete_zip_file = true
# the stream object is passed to the block and the stream is automatically
# closed afterwards just as with ruby's builtin File.open method.
def get_input_stream(entry, &aProc)
get_entry(entry).get_input_stream(&aProc)

if aProc
get_entry(entry).get_input_stream do |zis|
zis.password = @password if zis.respond_to? :password= # Make sure that tempfile does not call :password=
aProc.call(zis)
end
else
get_entry(entry).get_input_stream
end

end

# Returns an output stream to the specified entry. If entry is not an instance
Expand Down
34 changes: 28 additions & 6 deletions lib/zip/input_stream.rb
Expand Up @@ -129,17 +129,39 @@ def open_entry
@current_entry
end

def get_decompressor
case
when @current_entry.nil?
def get_decompressor(compression_method = nil)
compression_method ||= @current_entry.compression_method unless @current_entry.nil?
case compression_method
when nil
::Zip::NullDecompressor
when @current_entry.compression_method == ::Zip::Entry::STORED
when ::Zip::Entry::STORED
::Zip::PassThruDecompressor.new(@archive_io, @current_entry.size)
when @current_entry.compression_method == ::Zip::Entry::DEFLATED
when ::Zip::Entry::DEFLATED
::Zip::Inflater.new(@archive_io)
when ::Zip::Entry::ENCRYPTED
if @current_entry.extra["AES"].vendor_id != "AE"
raise ZipCompressionMethodError, "The #{@current_entry.extra["AES"].vendor_id} encryption method is not supported"
end

unless [1,2].include? @current_entry.extra["AES"].vendor_version
raise ZipCompressionMethodError, "Only AES-1 and AES-2 style encryption is supported"
end

if @current_entry.extra["AES"].compression_method == ::Zip::Entry::ENCRYPTED
# This would create infinite recursion.
raise ZipCompressionMethodError, "This zip file is malformed"
end

::Zip::Decrypter.new(
@archive_io,
@current_entry.extra["AES"].encryption_strength,
@current_entry.compressed_size,
get_decompressor(@current_entry.extra["AES"].compression_method)
)

else
raise ::Zip::CompressionMethodError,
"Unsupported compression method #{@current_entry.compression_method}"
"Unsupported compression method #{compression_method}"
end
end

Expand Down
2 changes: 2 additions & 0 deletions lib/zip/ioextras/abstract_input_stream.rb
Expand Up @@ -16,8 +16,10 @@ def initialize

attr_accessor :lineno
attr_reader :pos
attr_writer :password

def read(number_of_bytes = nil, buf = '')
@decompressor.password = @password if !@password.nil? and @decompressor.respond_to? :password=
tbuf = if @output_buffer.bytesize > 0
if number_of_bytes <= @output_buffer.bytesize
@output_buffer.slice!(0, number_of_bytes)
Expand Down