<?php
declare(strict_types=1);
namespace ParagonIE\Halite\Stream;
use \ParagonIE\Halite\Contract\{
StreamInterface,
KeyInterface
};
use \ParagonIE\Halite\Alerts as CryptoException;
use \ParagonIE\Halite\Util as CryptoUtil;
class ReadOnlyFile implements StreamInterface
{
const CHUNK = 8192; // PHP's fread() buffer is set to 8192 by default
private $closeAfter = false;
private $fp;
private $hash;
private $pos;
private $hashKey = null;
private $stat = [];
public function __construct($file, KeyInterface $key = null)
{
if (is_string($file)) {
$this->fp = \fopen($file, 'rb');
$this->closeAfter = true;
$this->pos = 0;
$this->stat = \fstat($this->fp);
} elseif (is_resource($file)) {
$this->fp = $file;
$this->pos = \ftell($this->fp);
$this->stat = \fstat($this->fp);
} else {
throw new \ParagonIE\Halite\Alerts\InvalidType(
'Argument 1: Expected a filename or resource'
);
}
$this->hashKey = !empty($key)
? $key->get()
: '';
$this->hash = $this->getHash();
}
public function close()
{
if ($this->closeAfter) {
$this->closeAfter = false;
\fclose($this->fp);
\clearstatcache();
}
}
public function __destruct()
{
$this->close();
}
/**
* Where are we in the buffer?
*
* @return int
*/
public function getPos()
{
return $this->pos;
}
/**
* How big is this buffer?
*
* @return int
*/
public function getSize()
{
return $this->stat['size'];
}
/**
* Read from a stream; prevent partial reads (also uses run-time testing to
* prevent partial reads -- you can turn this off if you need performance
* and aren't concerned about race condition attacks, but this isn't a
* decision to make lightly!)
*
* @param int $num
* @param bool $skipTests Only set this to TRUE if you're absolutely sure
* that you don't want to defend against TOCTOU /
* race condition attacks on the filesystem!
* @return string
* @throws CryptoException\AccessDenied
*/
public function readBytes(int $num, bool $skipTests = false): string
{
if ($num <= 0) {
throw new \Exception('num < 0');
}
if (($this->pos + $num) > $this->stat['size']) {
throw new \Exception('Out-of-bounds read');
}
$buf = '';
$remaining = $num;
if (!$skipTests) {
$this->toctouTest();
}
do {
if ($remaining <= 0) {
break;
}
$read = \fread($this->fp, $remaining);
if ($read === false) {
throw new CryptoException\FileAccessDenied(
'Could not read from the file'
);
}
$buf .= $read;
$readSize = CryptoUtil::safeStrlen($read);
$this->pos += $readSize;
$remaining -= $readSize;
} while ($remaining > 0);
return $buf;
}
/**
* Get number of bytes remaining
*
* @return int
*/
public function remainingBytes(): int
{
return (PHP_INT_MAX & ($this->stat['size'] - $this->pos));
}
/**
* This is a meaningless operation for a Read-Only File!
*
* @param string $buf
* @param int $num (number of bytes)
* @throws CryptoException\AccessDenied
*/
public function writeBytes(string $buf, int $num = null): int
{
unset($buf);
unset($num);
throw new CryptoException\FileAccessDenied(
'This is a read-only file handle.'
);
}
/**
* Set the current cursor position to the desired location
*
* @param int $i
* @return boolean
* @throws CryptoException\CannotPerformOperation
*/
public function reset(int $i = 0): bool
{
$this->pos = $i;
if (\fseek($this->fp, $i, SEEK_SET) === 0) {
return true;
}
throw new CryptoException\CannotPerformOperation(
'fseek() failed'
);
}
/**
* Calculate a BLAKE2b hash of a file
*
* @return string
*/
public function getHash(): string
{
$init = $this->pos;
\fseek($this->fp, 0, SEEK_SET);
// Create a hash context:
$h = \Sodium\crypto_generichash_init(
$this->hashKey,
\Sodium\CRYPTO_GENERICHASH_BYTES_MAX
);
for ($i = 0; $i < $this->stat['size']; $i += self::CHUNK) {
if (($i + self::CHUNK) > $this->stat['size']) {
$c = \fread($this->fp, ($this->stat['size'] - $i));
} else {
$c = \fread($this->fp, self::CHUNK);
}
\Sodium\crypto_generichash_update($h, $c);
}
// Reset the file pointer's internal cursor to where it was:
\fseek($this->fp, $init, SEEK_SET);
return \Sodium\crypto_generichash_final($h);
}
/**
* Run-time test to prevent TOCTOU attacks (race conditions) through
* verifying that the hash matches and the current cursor position/file
* size matches their values when the file was first opened.
*
* @throws CryptoException\FileModified
* @return true
*/
public function toctouTest()
{
if (\ftell($this->fp) !== $this->pos) {
throw new CryptoException\FileModified(
'Read-only file has been modified since it was opened for reading'
);
}
$stat = \fstat($this->fp);
if ($stat['size'] !== $this->stat['size']) {
throw new CryptoException\FileModified(
'Read-only file has been modified since it was opened for reading'
);
}
}
}
|