Skip to content

Commit 81340c9

Browse files
authored
Merge pull request #5 from xp-framework/feature/aes
Implement support for reading AES-enrypted entries
2 parents 91be845 + 6da96d3 commit 81340c9

File tree

9 files changed

+206
-50
lines changed

9 files changed

+206
-50
lines changed
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?php namespace io\archive\zip;
2+
3+
use io\streams\InputStream;
4+
use lang\IllegalStateException;
5+
6+
/**
7+
* Deciphers using little-endian variant of AES-CTR
8+
*
9+
* @ext openssl
10+
*/
11+
class AESInputStream implements InputStream {
12+
const BLOCK= 16;
13+
14+
private $in, $key;
15+
private $cipher, $counter, $hmac;
16+
private $buffer= '';
17+
18+
/**
19+
* Constructor
20+
*
21+
* @param io.streams.InputStream $in
22+
* @param string $key
23+
* @param string $auth
24+
*/
25+
public function __construct($in, $key, $auth) {
26+
$this->in= $in;
27+
$this->key= $key;
28+
29+
$this->cipher= 'aes-'.(strlen($key) * 8).'-ecb';
30+
$this->counter= "\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00";
31+
$this->hmac= hash_init('sha1', HASH_HMAC, $auth);
32+
}
33+
34+
/**
35+
* Decrypt, updating the HMAC and the counter while doing so
36+
*
37+
* @param string $input
38+
* @return string
39+
*/
40+
private function decrypt($input) {
41+
hash_update($this->hmac, $input);
42+
43+
$return= '';
44+
for ($offset= 0, $l= strlen($input); $offset < $l; $offset+= self::BLOCK) {
45+
46+
// Encrypt counter block using AES-ECB
47+
$keystream= openssl_encrypt(
48+
$this->counter,
49+
$this->cipher,
50+
$this->key,
51+
OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING
52+
);
53+
54+
// Take relevant part
55+
$return.= substr($input, $offset, self::BLOCK) ^ $keystream;
56+
57+
// Increment little-endian counter
58+
for ($j= 0, $carry= 1; $j < 16 && $carry; $j++) {
59+
$s= ord($this->counter[$j]) + $carry;
60+
$this->counter[$j]= chr($s & 0xff);
61+
$carry= $s >> 8;
62+
}
63+
}
64+
return $return;
65+
}
66+
67+
/**
68+
* Read a string
69+
*
70+
* @param int $limit default 8192
71+
* @return string
72+
* @throws lang.IllegalStateException when HMAC verification fails
73+
*/
74+
public function read($limit= 8192) {
75+
$chunk= $this->buffer.$this->in->read($limit);
76+
77+
// Ensure we always decrypt complete blocks while streaming
78+
if ($this->in->available()) {
79+
$rest= -strlen($chunk) % self::BLOCK;
80+
if ($rest) {
81+
$this->buffer= substr($chunk, $rest);
82+
return $this->decrypt(substr($chunk, 0, $rest));
83+
} else {
84+
$this->buffer= '';
85+
return $this->decrypt($chunk);
86+
}
87+
}
88+
89+
// Verify HMAC checksum for last block
90+
$this->buffer= '';
91+
$plain= $this->decrypt(substr($chunk, 0, -10));
92+
93+
$mac= hash_final($this->hmac, true);
94+
if (0 !== substr_compare($mac, substr($chunk, -10), 0, 10)) {
95+
throw new IllegalStateException('HMAC verification failed — corrupted data');
96+
}
97+
98+
return $plain;
99+
}
100+
101+
/**
102+
* Returns the number of bytes that can be read from this stream
103+
* without blocking.
104+
*
105+
* @return int
106+
*/
107+
public function available() {
108+
return $this->in->available();
109+
}
110+
111+
/**
112+
* Close this buffer
113+
*
114+
* @return void
115+
*/
116+
public function close() {
117+
$this->in->close();
118+
}
119+
}

src/main/php/io/archive/zip/AbstractZipReaderImpl.class.php

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
<?php namespace io\archive\zip;
22

33
use io\streams\InputStream;
4-
use lang\{FormatException, IllegalArgumentException};
5-
use util\Date;
4+
use lang\{FormatException, IllegalArgumentException, IllegalAccessException};
5+
use util\{Date, Secret};
66

77
/**
88
* Abstract base class for zip reader implementations
@@ -34,14 +34,13 @@ public function __construct(InputStream $stream) {
3434
/**
3535
* Set password to use when extracting
3636
*
37-
* @param string password
37+
* @param ?string|util.Secret $password
3838
*/
3939
public function setPassword($password) {
4040
if (null === $password) {
4141
$this->password= null;
4242
} else {
43-
$this->password= new ZipCipher();
44-
$this->password->initialize(iconv(\xp::ENCODING, 'cp437', $password));
43+
$this->password= $password instanceof Secret ? $password : new Secret($password);
4544
}
4645
}
4746

@@ -210,36 +209,65 @@ public function currentEntry() {
210209
if (!isset($this->index[$name])) throw new FormatException('.zip archive broken: cannot find "'.$name.'" in central directory.');
211210
$header= $this->index[$name];
212211

213-
// In case we're here, we can be sure to have a
214-
// RandomAccessStream - otherwise the central directory
215-
// could not have been read in the first place. So,
216-
// we may seek.
217-
// If we had strict type checking this would not be
218-
// possible, though.
212+
// In case we're here, we can be sure to have a RandomAccessStream - otherwise the
213+
// central directory could not have been read in the first place. So, we may seek.
219214
// The offset is relative to the file begin - but also skip over the usual parts:
220215
// * file header signature (4 bytes)
221216
// * file header (26 bytes)
222217
// * file extra + file name (variable size)
223-
$this->streamPosition($header['offset']+ 30 + $header['extralen'] + $header['namelen']);
218+
$this->streamPosition($header['offset'] + 30 + $header['extralen'] + $header['namelen']);
224219

225220
// Set skip accordingly: 4 bytes data descriptor signature + 12 bytes data descriptor
226-
$this->skip= $header['compressed']+ 16;
221+
$this->skip= $header['compressed'] + 16;
227222
}
228223

229-
// Bit 1: The file is encrypted
230-
if ($header['flags'] & 1) {
231-
$cipher= new ZipCipher($this->password);
224+
// AES vs. traditional PKZIP cipher
225+
if (99 === $header['compression']) {
226+
if (null === $this->password) {
227+
throw new IllegalAccessException('No password set');
228+
}
229+
230+
$aes= unpack('vheader/vsize/vversion/a2vendor/cstrength/vcompression', $extra);
231+
switch ($aes['strength']) {
232+
case 1: $sl= 8; $dl= 16; break;
233+
case 2: $sl= 12; $dl= 24; break;
234+
case 3: $sl= 16; $dl= 32; break;
235+
default: throw new IllegalArgumentException('Invalid AES strength '.$aes['strength']);
236+
}
237+
238+
// Verify password
239+
$salt= $this->streamRead($sl);
240+
$pvv= $this->streamRead(2);
241+
$dk= hash_pbkdf2('sha1', $this->password->reveal(), $salt, 1000, 2 * $dl + 2, true);
242+
if (0 !== substr_compare($dk, $pvv, 2 * $dl, 2)) {
243+
throw new IllegalAccessException('The password did not match');
244+
}
245+
246+
$this->skip-= $sl + 2;
247+
$header['compression']= $aes['compression'];
248+
$is= new AESInputStream(
249+
new ZipFileInputStream($this, $this->position, $header['compressed'] - $sl - 2),
250+
substr($dk, 0, $dl),
251+
substr($dk, $dl, $dl)
252+
);
253+
} else if ($header['flags'] & 1) {
254+
if (null === $this->password) {
255+
throw new IllegalAccessException('No password set');
256+
}
257+
258+
// Verify password
259+
$cipher= new ZipCipher();
260+
$cipher->initialize(iconv(\xp::ENCODING, 'cp437', $this->password->reveal()));
232261
$preamble= $cipher->decipher($this->streamRead(12));
233-
234-
// Verify
235-
if (ord($preamble[11]) !== (($header['crc'] >> 24) & 0xFF)) {
236-
throw new IllegalArgumentException('The password did not match ('.ord($preamble[11]).' vs. '.(($header['crc'] >> 24) & 0xFF).')');
262+
if (ord($preamble[11]) !== (($header['crc'] >> 24) & 0xff)) {
263+
throw new IllegalAccessException('The password did not match');
237264
}
238265

239-
// Password matches.
240-
$this->skip-= 12;
241-
$header['compressed']-= 12;
242-
$is= new DecipheringInputStream(new ZipFileInputStream($this, $this->position, $header['compressed']), $cipher);
266+
$this->skip-= 12;
267+
$is= new DecipheringInputStream(
268+
new ZipFileInputStream($this, $this->position, $header['compressed'] - 12),
269+
$cipher
270+
);
243271
} else {
244272
$is= new ZipFileInputStream($this, $this->position, $header['compressed']);
245273
}

src/main/php/io/archive/zip/ZipArchiveWriter.class.php

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
use io\streams\OutputStream;
44
use lang\{Closeable, IllegalArgumentException};
5-
use util\Date;
5+
use util\{Date, Secret};
66

77
/**
88
* Writes to a ZIP archive
@@ -52,15 +52,19 @@ public function usingUnicodeNames($unicode= true) {
5252
/**
5353
* Set password to use when adding entries
5454
*
55-
* @param string password
55+
* @param string|util.Secret $password
5656
* @return io.archive.zip.ZipArchiveWriter this
5757
*/
5858
public function usingPassword($password) {
5959
if (null === $password) {
6060
$this->password= null;
6161
} else {
6262
$this->password= new ZipCipher();
63-
$this->password->initialize(iconv(\xp::ENCODING, 'cp437', $password));
63+
$this->password->initialize(iconv(
64+
\xp::ENCODING,
65+
'cp437',
66+
$password instanceof Secret ? $password->reveal() : $password)
67+
);
6468
}
6569
return $this;
6670
}

src/main/php/io/archive/zip/ZipFileInputStream.class.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
<?php namespace io\archive\zip;
22

33
use io\IOException;
4+
use io\streams\InputStream;
45

56
/**
67
* Zip File input stream. Reads from the current position up until a
78
* certain length.
89
*/
9-
class ZipFileInputStream implements \io\streams\InputStream {
10+
class ZipFileInputStream implements InputStream {
1011
protected
1112
$reader = null,
1213
$start = 0,

src/test/php/io/archive/zip/unittest/AbstractZipFileTest.class.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
use io\archive\zip\{ZipArchiveReader, ZipEntry, ZipFile};
44
use io\streams\Streams;
55
use test\verify\Runtime;
6+
use util\Secret;
67

78
/**
89
* Base class for testing zip files
@@ -13,6 +14,12 @@
1314
#[Runtime(extensions: ['zlib'])]
1415
abstract class AbstractZipFileTest {
1516

17+
/** @return iterable */
18+
private function passwords() {
19+
yield ['secret'];
20+
yield [new Secret('secret')];
21+
}
22+
1623
/**
1724
* Returns entry content; or NULL for directories
1825
*

src/test/php/io/archive/zip/unittest/ZipArchiveWriterTest.class.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
use io\archive\zip\{ZipArchiveWriter, ZipDirEntry, ZipFile, ZipFileEntry};
44
use io\streams\{MemoryInputStream, MemoryOutputStream, StreamTransfer};
55
use lang\IllegalArgumentException;
6-
use test\{Assert, Expect, Test};
6+
use test\{Assert, Expect, Test, Values};
77

88
class ZipArchiveWriterTest extends AbstractZipFileTest {
99

@@ -83,11 +83,11 @@ public function adding_files_and_dir() {
8383
);
8484
}
8585

86-
#[Test]
87-
public function using_password_protection() {
86+
#[Test, Values(from: 'passwords')]
87+
public function using_password_protection($password) {
8888
$out= new MemoryOutputStream();
8989

90-
$fixture= ZipFile::create($out)->usingPassword('secret');
90+
$fixture= ZipFile::create($out)->usingPassword($password);
9191
$fixture->addFile(new ZipFileEntry('test.txt'))->out()->write('File contents');
9292
$fixture->close();
9393

src/test/php/io/archive/zip/unittest/vendors/SevenZipFileTest.class.php

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
<?php namespace io\archive\zip\unittest\vendors;
22

33
use io\streams\Streams;
4+
use lang\IllegalAccessException;
45
use test\verify\Runtime;
5-
use test\{Assert, Ignore, Test};
6+
use test\{Assert, Ignore, Expect, Test, Values};
67

78
/**
89
* Tests 7-ZIP archives
@@ -57,13 +58,19 @@ public function ppmd() {
5758
$this->assertCompressedEntryIn($this->archiveReaderFor($this->vendor(), 'ppmd'));
5859
}
5960

60-
/**
61-
* Assertion helper
62-
*
63-
* @param io.archive.zip.ZipArchiveReader reader
64-
* @throws unittest.AssertionFailedError
65-
*/
66-
protected function assertSecuredEntriesIn($reader) {
61+
#[Test, Expect(IllegalAccessException::class), Values(['zip-crypto', 'aes-128', 'aes-192', 'aes-256'])]
62+
public function missing_password($fixture) {
63+
$this->archiveReaderFor($this->vendor(), $fixture)->iterator()->next();
64+
}
65+
66+
#[Test, Expect(IllegalAccessException::class), Values(['zip-crypto', 'aes-128', 'aes-192', 'aes-256'])]
67+
public function incorrect_password($fixture) {
68+
$this->archiveReaderFor($this->vendor(), $fixture)->usingPassword('wrong')->iterator()->next();
69+
}
70+
71+
#[Test, Values(['zip-crypto', 'aes-128', 'aes-192', 'aes-256'])]
72+
public function password_protected($fixture) {
73+
$reader= $this->archiveReaderFor($this->vendor(), $fixture);
6774
with ($it= $reader->usingPassword('secret')->iterator()); {
6875
$entry= $it->next();
6976
Assert::equals('password.txt', $entry->getName());
@@ -76,14 +83,4 @@ protected function assertSecuredEntriesIn($reader) {
7683
Assert::equals('Very secret contents', (string)Streams::readAll($entry->in()));
7784
}
7885
}
79-
80-
#[Test]
81-
public function zipCryptoPasswordProtected() {
82-
$this->assertSecuredEntriesIn($this->archiveReaderFor($this->vendor(), 'zip-crypto'));
83-
}
84-
85-
#[Test, Ignore('Not yet supported')]
86-
public function aes256PasswordProtected() {
87-
$this->assertSecuredEntriesIn($this->archiveReaderFor($this->vendor(), 'aes-256'));
88-
}
8986
}
405 Bytes
Binary file not shown.
413 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)