Skip to content
119 changes: 119 additions & 0 deletions src/main/php/io/archive/zip/AESInputStream.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php namespace io\archive\zip;

use io\streams\InputStream;
use lang\IllegalStateException;

/**
* Deciphers using little-endian variant of AES-CTR
*
* @ext openssl
*/
class AESInputStream implements InputStream {
const BLOCK= 16;

private $in, $key;
private $cipher, $counter, $hmac;
private $buffer= '';

/**
* Constructor
*
* @param io.streams.InputStream $in
* @param string $key
* @param string $auth
*/
public function __construct($in, $key, $auth) {
$this->in= $in;
$this->key= $key;

$this->cipher= 'aes-'.(strlen($key) * 8).'-ecb';
$this->counter= "\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00";
$this->hmac= hash_init('sha1', HASH_HMAC, $auth);
}

/**
* Decrypt, updating the HMAC and the counter while doing so
*
* @param string $input
* @return string
*/
private function decrypt($input) {
hash_update($this->hmac, $input);

$return= '';
for ($offset= 0, $l= strlen($input); $offset < $l; $offset+= self::BLOCK) {

// Encrypt counter block using AES-ECB
$keystream= openssl_encrypt(
$this->counter,
$this->cipher,
$this->key,
OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING
);

// Take relevant part
$return.= substr($input, $offset, self::BLOCK) ^ $keystream;

// Increment little-endian counter
for ($j= 0, $carry= 1; $j < 16 && $carry; $j++) {
$s= ord($this->counter[$j]) + $carry;
$this->counter[$j]= chr($s & 0xff);
$carry= $s >> 8;
}
}
return $return;
}

/**
* Read a string
*
* @param int $limit default 8192
* @return string
* @throws lang.IllegalStateException when HMAC verification fails
*/
public function read($limit= 8192) {
$chunk= $this->buffer.$this->in->read($limit);

// Ensure we always decrypt complete blocks while streaming
if ($this->in->available()) {
$rest= -strlen($chunk) % self::BLOCK;
if ($rest) {
$this->buffer= substr($chunk, $rest);
return $this->decrypt(substr($chunk, 0, $rest));
} else {
$this->buffer= '';
return $this->decrypt($chunk);
}
}

// Verify HMAC checksum for last block
$this->buffer= '';
$plain= $this->decrypt(substr($chunk, 0, -10));

$mac= hash_final($this->hmac, true);
if (0 !== substr_compare($mac, substr($chunk, -10), 0, 10)) {
throw new IllegalStateException('HMAC verification failed — corrupted data');
}

return $plain;
}

/**
* Returns the number of bytes that can be read from this stream
* without blocking.
*
* @return int
*/
public function available() {
return $this->in->available();
}

/**
* Close this buffer
*
* @return void
*/
public function close() {
$this->in->close();
}
}
76 changes: 52 additions & 24 deletions src/main/php/io/archive/zip/AbstractZipReaderImpl.class.php
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<?php namespace io\archive\zip;

use io\streams\InputStream;
use lang\{FormatException, IllegalArgumentException};
use util\Date;
use lang\{FormatException, IllegalArgumentException, IllegalAccessException};
use util\{Date, Secret};

/**
* Abstract base class for zip reader implementations
Expand Down Expand Up @@ -34,14 +34,13 @@ public function __construct(InputStream $stream) {
/**
* Set password to use when extracting
*
* @param string password
* @param ?string|util.Secret $password
*/
public function setPassword($password) {
if (null === $password) {
$this->password= null;
} else {
$this->password= new ZipCipher();
$this->password->initialize(iconv(\xp::ENCODING, 'cp437', $password));
$this->password= $password instanceof Secret ? $password : new Secret($password);
}
}

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

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

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

// Bit 1: The file is encrypted
if ($header['flags'] & 1) {
$cipher= new ZipCipher($this->password);
// AES vs. traditional PKZIP cipher
if (99 === $header['compression']) {
if (null === $this->password) {
throw new IllegalAccessException('No password set');
}

$aes= unpack('vheader/vsize/vversion/a2vendor/cstrength/vcompression', $extra);
switch ($aes['strength']) {
case 1: $sl= 8; $dl= 16; break;
case 2: $sl= 12; $dl= 24; break;
case 3: $sl= 16; $dl= 32; break;
default: throw new IllegalArgumentException('Invalid AES strength '.$aes['strength']);
}

// Verify password
$salt= $this->streamRead($sl);
$pvv= $this->streamRead(2);
$dk= hash_pbkdf2('sha1', $this->password->reveal(), $salt, 1000, 2 * $dl + 2, true);
if (0 !== substr_compare($dk, $pvv, 2 * $dl, 2)) {
throw new IllegalAccessException('The password did not match');
}

$this->skip-= $sl + 2;
$header['compression']= $aes['compression'];
$is= new AESInputStream(
new ZipFileInputStream($this, $this->position, $header['compressed'] - $sl - 2),
substr($dk, 0, $dl),
substr($dk, $dl, $dl)
);
} else if ($header['flags'] & 1) {
if (null === $this->password) {
throw new IllegalAccessException('No password set');
}

// Verify password
$cipher= new ZipCipher();
$cipher->initialize(iconv(\xp::ENCODING, 'cp437', $this->password->reveal()));
$preamble= $cipher->decipher($this->streamRead(12));

// Verify
if (ord($preamble[11]) !== (($header['crc'] >> 24) & 0xFF)) {
throw new IllegalArgumentException('The password did not match ('.ord($preamble[11]).' vs. '.(($header['crc'] >> 24) & 0xFF).')');
if (ord($preamble[11]) !== (($header['crc'] >> 24) & 0xff)) {
throw new IllegalAccessException('The password did not match');
}

// Password matches.
$this->skip-= 12;
$header['compressed']-= 12;
$is= new DecipheringInputStream(new ZipFileInputStream($this, $this->position, $header['compressed']), $cipher);
$this->skip-= 12;
$is= new DecipheringInputStream(
new ZipFileInputStream($this, $this->position, $header['compressed'] - 12),
$cipher
);
} else {
$is= new ZipFileInputStream($this, $this->position, $header['compressed']);
}
Expand Down
10 changes: 7 additions & 3 deletions src/main/php/io/archive/zip/ZipArchiveWriter.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use io\streams\OutputStream;
use lang\{Closeable, IllegalArgumentException};
use util\Date;
use util\{Date, Secret};

/**
* Writes to a ZIP archive
Expand Down Expand Up @@ -52,15 +52,19 @@ public function usingUnicodeNames($unicode= true) {
/**
* Set password to use when adding entries
*
* @param string password
* @param string|util.Secret $password
* @return io.archive.zip.ZipArchiveWriter this
*/
public function usingPassword($password) {
if (null === $password) {
$this->password= null;
} else {
$this->password= new ZipCipher();
$this->password->initialize(iconv(\xp::ENCODING, 'cp437', $password));
$this->password->initialize(iconv(
\xp::ENCODING,
'cp437',
$password instanceof Secret ? $password->reveal() : $password)
);
}
return $this;
}
Expand Down
3 changes: 2 additions & 1 deletion src/main/php/io/archive/zip/ZipFileInputStream.class.php
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
<?php namespace io\archive\zip;

use io\IOException;
use io\streams\InputStream;

/**
* Zip File input stream. Reads from the current position up until a
* certain length.
*/
class ZipFileInputStream implements \io\streams\InputStream {
class ZipFileInputStream implements InputStream {
protected
$reader = null,
$start = 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
use io\archive\zip\{ZipArchiveReader, ZipEntry, ZipFile};
use io\streams\Streams;
use test\verify\Runtime;
use util\Secret;

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

/** @return iterable */
private function passwords() {
yield ['secret'];
yield [new Secret('secret')];
}

/**
* Returns entry content; or NULL for directories
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use io\archive\zip\{ZipArchiveWriter, ZipDirEntry, ZipFile, ZipFileEntry};
use io\streams\{MemoryInputStream, MemoryOutputStream, StreamTransfer};
use lang\IllegalArgumentException;
use test\{Assert, Expect, Test};
use test\{Assert, Expect, Test, Values};

class ZipArchiveWriterTest extends AbstractZipFileTest {

Expand Down Expand Up @@ -83,11 +83,11 @@ public function adding_files_and_dir() {
);
}

#[Test]
public function using_password_protection() {
#[Test, Values(from: 'passwords')]
public function using_password_protection($password) {
$out= new MemoryOutputStream();

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

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
<?php namespace io\archive\zip\unittest\vendors;

use io\streams\Streams;
use lang\IllegalAccessException;
use test\verify\Runtime;
use test\{Assert, Ignore, Test};
use test\{Assert, Ignore, Expect, Test, Values};

/**
* Tests 7-ZIP archives
Expand Down Expand Up @@ -57,13 +58,19 @@ public function ppmd() {
$this->assertCompressedEntryIn($this->archiveReaderFor($this->vendor(), 'ppmd'));
}

/**
* Assertion helper
*
* @param io.archive.zip.ZipArchiveReader reader
* @throws unittest.AssertionFailedError
*/
protected function assertSecuredEntriesIn($reader) {
#[Test, Expect(IllegalAccessException::class), Values(['zip-crypto', 'aes-128', 'aes-192', 'aes-256'])]
public function missing_password($fixture) {
$this->archiveReaderFor($this->vendor(), $fixture)->iterator()->next();
}

#[Test, Expect(IllegalAccessException::class), Values(['zip-crypto', 'aes-128', 'aes-192', 'aes-256'])]
public function incorrect_password($fixture) {
$this->archiveReaderFor($this->vendor(), $fixture)->usingPassword('wrong')->iterator()->next();
}

#[Test, Values(['zip-crypto', 'aes-128', 'aes-192', 'aes-256'])]
public function password_protected($fixture) {
$reader= $this->archiveReaderFor($this->vendor(), $fixture);
with ($it= $reader->usingPassword('secret')->iterator()); {
$entry= $it->next();
Assert::equals('password.txt', $entry->getName());
Expand All @@ -76,14 +83,4 @@ protected function assertSecuredEntriesIn($reader) {
Assert::equals('Very secret contents', (string)Streams::readAll($entry->in()));
}
}

#[Test]
public function zipCryptoPasswordProtected() {
$this->assertSecuredEntriesIn($this->archiveReaderFor($this->vendor(), 'zip-crypto'));
}

#[Test, Ignore('Not yet supported')]
public function aes256PasswordProtected() {
$this->assertSecuredEntriesIn($this->archiveReaderFor($this->vendor(), 'aes-256'));
}
}
Binary file not shown.
Binary file not shown.
Loading