Skip to content

Commit 059da9c

Browse files
jplothainesr
andcommitted
Add support for AES decryption.
Co-authored-by: Robert Haines <rhaines@manchester.ac.uk> Reviewed-by: @Ph0tonic
1 parent 1eecb6b commit 059da9c

File tree

8 files changed

+214
-5
lines changed

8 files changed

+214
-5
lines changed

.rubocop_todo.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Gemspec/DevelopmentDependencies:
1313
Lint/MissingSuper:
1414
Exclude:
1515
- 'lib/zip/extra_field.rb'
16+
- 'lib/zip/extra_field/aes.rb'
1617
- 'lib/zip/extra_field/ntfs.rb'
1718
- 'lib/zip/extra_field/old_unix.rb'
1819
- 'lib/zip/extra_field/universal_time.rb'
@@ -23,7 +24,7 @@ Lint/MissingSuper:
2324
# Offense count: 5
2425
# Configuration parameters: CountComments, CountAsOne.
2526
Metrics/ClassLength:
26-
Max: 650
27+
Max: 675
2728

2829
# Offense count: 21
2930
# Configuration parameters: IgnoredMethods.
@@ -60,6 +61,7 @@ Naming/AccessorMethodName:
6061
Style/ClassAndModuleChildren:
6162
Exclude:
6263
- 'lib/zip/extra_field/generic.rb'
64+
- 'lib/zip/extra_field/aes.rb'
6365
- 'lib/zip/extra_field/ntfs.rb'
6466
- 'lib/zip/extra_field/old_unix.rb'
6567
- 'lib/zip/extra_field/universal_time.rb'

lib/zip.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
require 'zip/crypto/encryption'
3131
require 'zip/crypto/null_encryption'
3232
require 'zip/crypto/traditional_encryption'
33+
require 'zip/crypto/aes_encryption'
3334
require 'zip/inflater'
3435
require 'zip/deflater'
3536
require 'zip/streamable_stream'

lib/zip/crypto/aes_encryption.rb

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# frozen_string_literal: true
2+
3+
require 'openssl'
4+
5+
module Zip
6+
module AESEncryption # :nodoc:
7+
VERIFIER_LENGTH = 2
8+
BLOCK_SIZE = 16
9+
AUTHENTICATION_CODE_LENGTH = 10
10+
11+
VERSION_AE_1 = 0x01
12+
VERSION_AE_2 = 0x02
13+
14+
VERSIONS = [
15+
VERSION_AE_1,
16+
VERSION_AE_2
17+
].freeze
18+
19+
STRENGTH_128_BIT = 0x01
20+
STRENGTH_192_BIT = 0x02
21+
STRENGTH_256_BIT = 0x03
22+
23+
STRENGTHS = [
24+
STRENGTH_128_BIT,
25+
STRENGTH_192_BIT,
26+
STRENGTH_256_BIT
27+
].freeze
28+
29+
BITS = {
30+
STRENGTH_128_BIT => 128,
31+
STRENGTH_192_BIT => 192,
32+
STRENGTH_256_BIT => 256
33+
}.freeze
34+
35+
KEY_LENGTHS = {
36+
STRENGTH_128_BIT => 16,
37+
STRENGTH_192_BIT => 24,
38+
STRENGTH_256_BIT => 32
39+
}.freeze
40+
41+
SALT_LENGTHS = {
42+
STRENGTH_128_BIT => 8,
43+
STRENGTH_192_BIT => 12,
44+
STRENGTH_256_BIT => 16
45+
}.freeze
46+
47+
def initialize(password, strength)
48+
@password = password
49+
@strength = strength
50+
@bits = BITS[@strength]
51+
@key_length = KEY_LENGTHS[@strength]
52+
@salt_length = SALT_LENGTHS[@strength]
53+
end
54+
55+
def header_bytesize
56+
@salt_length + VERIFIER_LENGTH
57+
end
58+
59+
def gp_flags
60+
0x0001
61+
end
62+
end
63+
64+
class AESDecrypter < Decrypter # :nodoc:
65+
include AESEncryption
66+
67+
def decrypt(encrypted_data)
68+
@hmac.update(encrypted_data)
69+
70+
idx = 0
71+
decrypted_data = +''
72+
amount_to_read = encrypted_data.size
73+
74+
while amount_to_read.positive?
75+
@cipher.iv = [@counter + 1].pack('Vx12')
76+
begin_index = BLOCK_SIZE * idx
77+
end_index = begin_index + [BLOCK_SIZE, amount_to_read].min
78+
decrypted_data << @cipher.update(encrypted_data[begin_index...end_index])
79+
amount_to_read -= BLOCK_SIZE
80+
@counter += 1
81+
idx += 1
82+
end
83+
84+
decrypted_data
85+
end
86+
87+
def reset!(header)
88+
raise Error, "Unsupported encryption AES-#{@bits}" unless STRENGTHS.include? @strength
89+
90+
salt = header[0...@salt_length]
91+
pwd_verify = header[-VERIFIER_LENGTH..]
92+
key_material = OpenSSL::KDF.pbkdf2_hmac(
93+
@password,
94+
salt: salt,
95+
iterations: 1000,
96+
length: (2 * @key_length) + VERIFIER_LENGTH,
97+
hash: 'sha1'
98+
)
99+
enc_key = key_material[0...@key_length]
100+
enc_hmac_key = key_material[@key_length...(2 * @key_length)]
101+
enc_pwd_verify = key_material[-VERIFIER_LENGTH..]
102+
103+
raise Error, 'Bad password' if enc_pwd_verify != pwd_verify
104+
105+
@counter = 0
106+
@cipher = OpenSSL::Cipher::AES.new(@bits, :CTR)
107+
@cipher.decrypt
108+
@cipher.key = enc_key
109+
@hmac = OpenSSL::HMAC.new(enc_hmac_key, OpenSSL::Digest.new('SHA1'))
110+
end
111+
112+
def check_integrity(auth_code)
113+
raise Error, 'Integrity fault' if @hmac.digest[0...AUTHENTICATION_CODE_LENGTH] != auth_code
114+
end
115+
end
116+
end

lib/zip/crypto/decrypted_io.rb

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ module Zip
44
class DecryptedIo # :nodoc:all
55
CHUNK_SIZE = 32_768
66

7-
def initialize(io, decrypter)
7+
def initialize(io, decrypter, compressed_size)
88
@io = io
99
@decrypter = decrypter
10+
@offset = io.tell
11+
@compressed_size = compressed_size
1012
end
1113

1214
def read(length = nil, outbuf = +'')
@@ -18,6 +20,10 @@ def read(length = nil, outbuf = +'')
1820
buffer << produce_input
1921
end
2022

23+
if @decrypter.kind_of?(::Zip::AESDecrypter) && input_finished?
24+
@decrypter.check_integrity(@io.read(::Zip::AESEncryption::AUTHENTICATION_CODE_LENGTH))
25+
end
26+
2127
outbuf.replace(buffer.slice!(0...(length || buffer.bytesize)))
2228
end
2329

@@ -31,12 +37,17 @@ def buffer
3137
@buffer ||= +''
3238
end
3339

40+
def pos
41+
@io.tell - @offset
42+
end
43+
3444
def input_finished?
35-
@io.eof
45+
@io.eof || pos >= @compressed_size
3646
end
3747

3848
def produce_input
39-
@decrypter.decrypt(@io.read(CHUNK_SIZE))
49+
chunk_size = [CHUNK_SIZE, @compressed_size - pos].min
50+
@decrypter.decrypt(@io.read(chunk_size))
4051
end
4152
end
4253
end

lib/zip/entry.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,10 @@ def zip64?
203203
!@extra['Zip64'].nil?
204204
end
205205

206+
def aes?
207+
!@extra['AES'].nil?
208+
end
209+
206210
def file_type_is?(type) # :nodoc:
207211
ftype == type
208212
end
@@ -382,6 +386,7 @@ def read_local_entry(io) # :nodoc:
382386

383387
read_extra_field(extra, local: true)
384388
parse_zip64_extra(true)
389+
parse_aes_extra
385390
@local_header_size = calculate_local_header_size
386391
end
387392

@@ -511,6 +516,7 @@ def read_c_dir_entry(io) # :nodoc:
511516
check_c_dir_entry_comment_size
512517
set_ftype_from_c_dir_entry
513518
parse_zip64_extra(false)
519+
parse_aes_extra
514520
end
515521

516522
def file_stat(path) # :nodoc:
@@ -800,6 +806,20 @@ def parse_zip64_extra(for_local_header) # :nodoc:
800806
end
801807
end
802808

809+
def parse_aes_extra # :nodoc:
810+
return unless aes?
811+
812+
if @extra['AES'].vendor_id != 'AE'
813+
raise Error, "Unsupported encryption method #{@extra['AES'].vendor_id}"
814+
end
815+
816+
unless ::Zip::AESEncryption::VERSIONS.include? @extra['AES'].vendor_version
817+
raise Error, "Unsupported encryption style #{@extra['AES'].vendor_version}"
818+
end
819+
820+
@compression_method = @extra['AES'].compression_method if ftype != :directory
821+
end
822+
803823
# For DEFLATED compression *only*: set the general purpose flags 1 and 2 to
804824
# indicate compression level. This seems to be mainly cosmetic but they are
805825
# generally set by other tools - including in docx files. It is these flags

lib/zip/extra_field.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ def local_size
9191
require 'zip/extra_field/unix'
9292
require 'zip/extra_field/zip64'
9393
require 'zip/extra_field/ntfs'
94+
require 'zip/extra_field/aes'
9495

9596
# Copyright (C) 2002, 2003 Thomas Sondergaard
9697
# rubyzip is free software; you can redistribute it and/or

lib/zip/extra_field/aes.rb

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# frozen_string_literal: true
2+
3+
module Zip
4+
# Info-ZIP Extra for AES encryption
5+
class ExtraField::AES < ExtraField::Generic # :nodoc:
6+
attr_reader :vendor_version, :vendor_id, :encryption_strength, :compression_method
7+
8+
HEADER_ID = [0x9901].pack('v')
9+
register_map
10+
11+
def initialize(binstr = nil)
12+
@vendor_version = nil
13+
@vendor_id = nil
14+
@encryption_strength = nil
15+
@compression_method = nil
16+
binstr && merge(binstr)
17+
end
18+
19+
def ==(other)
20+
@vendor_version == other.vendor_version &&
21+
@vendor_id == other.vendor_id &&
22+
@encryption_strength == other.encryption_strength &&
23+
@compression_method == other.compression_method
24+
end
25+
26+
def merge(binstr)
27+
return if binstr.empty?
28+
29+
size, content = initial_parse(binstr)
30+
(size && content) || return
31+
32+
@vendor_version, @vendor_id,
33+
@encryption_strength, @compression_method = content.unpack('va2Cv')
34+
end
35+
36+
def pack_for_local
37+
[@vendor_version, @vendor_id,
38+
@encryption_strength, @compression_method].pack('va2Cv')
39+
end
40+
41+
def pack_for_c_dir
42+
pack_for_local
43+
end
44+
end
45+
end

lib/zip/input_stream.rb

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,20 @@ def get_decrypted_io # :nodoc:
154154
header = @archive_io.read(@decrypter.header_bytesize)
155155
@decrypter.reset!(header)
156156

157-
::Zip::DecryptedIo.new(@archive_io, @decrypter)
157+
compressed_size =
158+
if @current_entry.incomplete? && @current_entry.crc == 0 &&
159+
@current_entry.compressed_size == 0 && @complete_entry
160+
@complete_entry.compressed_size
161+
else
162+
@current_entry.compressed_size
163+
end
164+
165+
if @decrypter.kind_of?(::Zip::AESDecrypter)
166+
compressed_size -= @decrypter.header_bytesize
167+
compressed_size -= ::Zip::AESEncryption::AUTHENTICATION_CODE_LENGTH
168+
end
169+
170+
::Zip::DecryptedIo.new(@archive_io, @decrypter, compressed_size)
158171
end
159172

160173
def get_decompressor # :nodoc:

0 commit comments

Comments
 (0)