feat(wopi): support for wopi proof key

Signed-off-by: Elizabeth Danzberger <lizzy7128@tutanota.de>
This commit is contained in:
Elizabeth Danzberger 2025-07-22 14:21:10 -04:00 committed by Elizabeth Danzberger
parent e8696ff5ea
commit 7471c8f7f7
No known key found for this signature in database
GPG key ID: 6B466A21DF5E753C
10 changed files with 738 additions and 4 deletions

View file

@ -17,7 +17,8 @@
"ext-json": "*",
"ext-simplexml": "*",
"mikehaertl/php-pdftk": "^0.13.1",
"league/commonmark": "^2.7"
"league/commonmark": "^2.7",
"phpseclib/phpseclib": "^3.0"
},
"require-dev": {
"roave/security-advisories": "dev-master",

229
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "353c5fd52a94e341731dc234cf9f453e",
"content-hash": "9545cacbcf237baa0ff001d052733de1",
"packages": [
{
"name": "dflydev/dot-access-data",
@ -557,6 +557,233 @@
},
"time": "2025-06-03T04:55:08+00:00"
},
{
"name": "paragonie/constant_time_encoding",
"version": "v3.0.0",
"source": {
"type": "git",
"url": "https://github.com/paragonie/constant_time_encoding.git",
"reference": "df1e7fde177501eee2037dd159cf04f5f301a512"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/df1e7fde177501eee2037dd159cf04f5f301a512",
"reference": "df1e7fde177501eee2037dd159cf04f5f301a512",
"shasum": ""
},
"require": {
"php": "^8"
},
"require-dev": {
"phpunit/phpunit": "^9",
"vimeo/psalm": "^4|^5"
},
"type": "library",
"autoload": {
"psr-4": {
"ParagonIE\\ConstantTime\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com",
"role": "Maintainer"
},
{
"name": "Steve 'Sc00bz' Thomas",
"email": "steve@tobtu.com",
"homepage": "https://www.tobtu.com",
"role": "Original Developer"
}
],
"description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
"keywords": [
"base16",
"base32",
"base32_decode",
"base32_encode",
"base64",
"base64_decode",
"base64_encode",
"bin2hex",
"encoding",
"hex",
"hex2bin",
"rfc4648"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
"source": "https://github.com/paragonie/constant_time_encoding"
},
"time": "2024-05-08T12:36:18+00:00"
},
{
"name": "paragonie/random_compat",
"version": "v9.99.100",
"source": {
"type": "git",
"url": "https://github.com/paragonie/random_compat.git",
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a",
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
"shasum": ""
},
"require": {
"php": ">= 7"
},
"require-dev": {
"phpunit/phpunit": "4.*|5.*",
"vimeo/psalm": "^1"
},
"suggest": {
"ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
},
"type": "library",
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com"
}
],
"description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
"keywords": [
"csprng",
"polyfill",
"pseudorandom",
"random"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/random_compat/issues",
"source": "https://github.com/paragonie/random_compat"
},
"time": "2020-10-15T08:29:30+00:00"
},
{
"name": "phpseclib/phpseclib",
"version": "3.0.46",
"source": {
"type": "git",
"url": "https://github.com/phpseclib/phpseclib.git",
"reference": "56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6",
"reference": "56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6",
"shasum": ""
},
"require": {
"paragonie/constant_time_encoding": "^1|^2|^3",
"paragonie/random_compat": "^1.4|^2.0|^9.99.99",
"php": ">=5.6.1"
},
"require-dev": {
"phpunit/phpunit": "*"
},
"suggest": {
"ext-dom": "Install the DOM extension to load XML formatted public keys.",
"ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.",
"ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.",
"ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.",
"ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations."
},
"type": "library",
"autoload": {
"files": [
"phpseclib/bootstrap.php"
],
"psr-4": {
"phpseclib3\\": "phpseclib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jim Wigginton",
"email": "terrafrost@php.net",
"role": "Lead Developer"
},
{
"name": "Patrick Monnerat",
"email": "pm@datasphere.ch",
"role": "Developer"
},
{
"name": "Andreas Fischer",
"email": "bantu@phpbb.com",
"role": "Developer"
},
{
"name": "Hans-Jürgen Petrich",
"email": "petrich@tronic-media.com",
"role": "Developer"
},
{
"name": "Graham Campbell",
"email": "graham@alt-three.com",
"role": "Developer"
}
],
"description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.",
"homepage": "http://phpseclib.sourceforge.net",
"keywords": [
"BigInteger",
"aes",
"asn.1",
"asn1",
"blowfish",
"crypto",
"cryptography",
"encryption",
"rsa",
"security",
"sftp",
"signature",
"signing",
"ssh",
"twofish",
"x.509",
"x509"
],
"support": {
"issues": "https://github.com/phpseclib/phpseclib/issues",
"source": "https://github.com/phpseclib/phpseclib/tree/3.0.46"
},
"funding": [
{
"url": "https://github.com/terrafrost",
"type": "github"
},
{
"url": "https://www.patreon.com/phpseclib",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib",
"type": "tidelift"
}
],
"time": "2025-06-26T16:29:55+00:00"
},
{
"name": "psr/event-dispatcher",
"version": "1.0.0",

View file

@ -0,0 +1,9 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Richdocuments\Exceptions;
class WopiException extends \Exception {
}

View file

@ -14,7 +14,10 @@ use OCA\Richdocuments\Controller\WopiController;
use OCA\Richdocuments\Db\WopiMapper;
use OCA\Richdocuments\Exceptions\ExpiredTokenException;
use OCA\Richdocuments\Exceptions\UnknownTokenException;
use OCA\Richdocuments\Exceptions\WopiException;
use OCA\Richdocuments\Helper;
use OCA\Richdocuments\Service\DiscoveryService;
use OCA\Richdocuments\Service\ProofKeyService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\Response;
@ -22,6 +25,7 @@ use OCP\AppFramework\Middleware;
use OCP\Files\NotPermittedException;
use OCP\IConfig;
use OCP\IRequest;
use OCP\IURLGenerator;
use Psr\Log\LoggerInterface;
use ReflectionClass;
use ReflectionMethod;
@ -30,9 +34,12 @@ use Symfony\Component\HttpFoundation\IpUtils;
class WOPIMiddleware extends Middleware {
public function __construct(
private IConfig $config,
private IURLGenerator $urlGenerator,
private IRequest $request,
private DiscoveryService $discoveryService,
private WopiMapper $wopiMapper,
private LoggerInterface $logger,
private ProofKeyService $proofKeyService,
private bool $isWOPIRequest = false,
) {
}
@ -56,13 +63,52 @@ class WOPIMiddleware extends Middleware {
return;
}
if (strpos($this->request->getRequestUri(), 'wopi/settings/upload') !== false) {
if (str_contains($this->request->getRequestUri(), '/wopi/settings/upload')) {
return;
}
try {
$fileId = $this->request->getParam('fileId');
$accessToken = $this->request->getParam('access_token');
$isWopiSettingsUrl = str_contains($this->request->getRequestUri(), '/wopi/settings');
if (!$isWopiSettingsUrl) {
$wopiProof = $this->request->getHeader('X-WOPI-Proof');
$wopiProofOld = $this->request->getHeader('X-WOPI-ProofOld');
$hasProofKey = $this->discoveryService->hasProofKey();
// This could mean the discovery cache needs to be updated
// e.g. if Collabora sends a WOPI proof but the cached discovery
// says there is not one, then we should re-fetch it
if ($hasProofKey !== (bool)$wopiProof) {
$this->discoveryService->fetch();
$hasProofKey = $this->discoveryService->hasProofKey();
}
if ($hasProofKey) {
$wopiTimestamp = $this->request->getHeader('X-WOPI-TimeStamp');
$wopiTimestampIsOld = $this->proofKeyService->isOldTimestamp((int)$wopiTimestamp);
if ($wopiTimestampIsOld) {
throw new WopiException('X-WOPI-TimeStamp header is older than 20 minutes');
}
$url = $this->urlGenerator->getBaseUrl() . $this->request->getRequestUri();
$isProofValid = $this->proofKeyService->isProofValid(
$accessToken,
$url,
$wopiTimestamp,
$wopiProof,
$wopiProofOld
);
if (!$isProofValid) {
throw new WopiException('Invalid WOPI proof');
}
}
}
$fileId = $this->request->getParam('fileId');
[$fileId, ,] = Helper::parseFileId($fileId);
$wopi = $this->wopiMapper->getWopiForToken($accessToken);
if ((int)$fileId !== $wopi->getFileid() && (int)$fileId !== $wopi->getTemplateId()) {
@ -75,6 +121,11 @@ class WOPIMiddleware extends Middleware {
$this->logger->info('Invalid token for WOPI access', [ 'exception' => $e ]);
}
throw new NotPermittedException();
} catch (WopiException $e) {
$this->logger->error('WOPI error: ' . $e->getMessage(), [
'exception' => $e,
]);
throw new WopiException();
} catch (\Exception $e) {
$this->logger->error('Failed to validate WOPI access', [ 'exception' => $e ]);
throw new NotPermittedException();
@ -84,6 +135,10 @@ class WOPIMiddleware extends Middleware {
}
public function afterException($controller, $methodName, \Exception $exception): Response {
if ($exception instanceof WopiException && $controller instanceof WopiController) {
return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR);
}
if ($exception instanceof NotPermittedException && $controller instanceof WopiController) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}

View file

@ -28,6 +28,7 @@ declare(strict_types=1);
namespace OCA\Richdocuments\Service;
use OCA\Richdocuments\WOPI\ProofKey;
use OCP\Files\AppData\IAppDataFactory;
use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService;
@ -35,6 +36,7 @@ use OCP\IAppConfig;
use OCP\ICacheFactory;
use OCP\IConfig;
use Psr\Log\LoggerInterface;
use SimpleXMLElement;
class DiscoveryService extends CachedRequestService {
public function __construct(
@ -63,6 +65,64 @@ class DiscoveryService extends CachedRequestService {
private function getDiscoveryEndpoint(): string {
$remoteHost = $this->config->getAppValue('richdocuments', 'wopi_url');
return rtrim($remoteHost, '/') . '/hosting/discovery';
}
public function hasProofKey(): bool {
try {
$parsed = $this->getParsed();
} catch (\Exception $e) {
return false;
}
if (!$parsed->xpath('//proof-key')) {
return false;
}
return (bool)$parsed->xpath('//proof-key');
}
public function getProofKey(): ?ProofKey {
try {
$parsed = $this->getParsed();
} catch (\Exception $e) {
return null;
}
$publicKey = (string)$parsed->xpath('//proof-key/@value')[0];
$modulus = (string)$parsed->xpath('//proof-key/@modulus')[0];
$exponent = (string)$parsed->xpath('//proof-key/@exponent')[0];
return new ProofKey(
$exponent,
$modulus,
$publicKey
);
}
public function getProofKeyOld(): ?ProofKey {
try {
$parsed = $this->getParsed();
} catch (\Exception $e) {
return null;
}
$publicKey = (string)$parsed->xpath('//proof-key/@oldvalue')[0];
$modulus = (string)$parsed->xpath('//proof-key/@oldmodulus')[0];
$exponent = (string)$parsed->xpath('//proof-key/@oldexponent')[0];
return new ProofKey(
$exponent,
$modulus,
$publicKey
);
}
private function getParsed(): SimpleXMLElement {
try {
return new SimpleXMLElement($this->get());
} catch (\Exception $e) {
$this->logger->error($e->getMessage());
throw new \Exception('Could not parse discovery XML');
}
}
}

View file

@ -0,0 +1,147 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Richdocuments\Service;
use DateTime;
use DateTimeImmutable;
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Crypt\RSA;
use phpseclib3\Math\BigInteger;
use Throwable;
class ProofKeyService {
// The Windows epoch is used for WOPI timestamps (as it is a MS protocol)
// Notes: According to the MS documentation it begins on 01-01-0001
// but all evidence in practice points to 01-01-1601
private const WINDOWS_EPOCH = '01-01-1601 00:00:00';
private const UNIX_EPOCH = '01-01-1970 00:00:00';
public function __construct(
private DiscoveryService $discoveryService,
) {
}
public function isProofValid(string $accessToken, string $url, string $wopiTimestamp, string $proof, string $proofOld): bool {
$expected = $this->constructProof(
$accessToken,
$url,
$wopiTimestamp
);
$proofKey = $this->discoveryService->getProofKey();
$proofKeyOld = $this->discoveryService->getProofKeyOld();
$key = $this->calculateRSAKey($proofKey->getModulus(), $proofKey->getExponent());
$keyOld = $this->calculateRSAKey($proofKeyOld->getModulus(), $proofKey->getExponent());
$isValid = ($this->verifyKey($expected, $proof, $key) ||
$this->verifyKey($expected, $proof, $keyOld) ||
$this->verifyKey($expected, $proofOld, $key));
return $isValid;
}
public function windowsToUnixTimestamp(string $windowsTimestamp): string {
// Convert the epochs to timestamps
$windowsEpoch = strtotime(self::WINDOWS_EPOCH);
$unixEpoch = strtotime(self::UNIX_EPOCH);
// Calculate the difference between the Unix and Windows epochs in seconds
$epochOffset = (float)($unixEpoch - $windowsEpoch);
// Convert the Windows timestamp from 100-nanoseconds intervals to seconds
$windowsTimestampSeconds = ((float)$windowsTimestamp) / 1e7;
// Finally, subtract the number of seconds between the Windows and Unix epochs
// from the number of seconds in the given Windows timestamp
$convertedWindowsTimestamp = (int)($windowsTimestampSeconds - $epochOffset);
return (string)$convertedWindowsTimestamp;
}
public function isOldTimestamp(int $timestamp): bool {
$timestampDateTime = new DateTime();
$timestampDateTime->setTimestamp($timestamp);
$now = new DateTimeImmutable();
$controlDateTime = $now->modify('-20 minutes');
// The timestamp is old if it is from over 20 minutes ago
if ($timestampDateTime < $controlDateTime) {
return true;
}
return false;
}
private function calculateRSAKey(string $modulus, string $exponent): string {
$rsa = PublicKeyLoader::load([
'e' => new BigInteger(base64_decode($exponent, true), 256),
'n' => new BigInteger(base64_decode($modulus, true), 256),
]);
return (string)$rsa->__toString();
}
private function constructProof(
string $accessToken,
string $url,
string $wopiTimeStamp,
): string {
// Four bytes representing the length, in bytes, of the access token
$accessTokenLength = pack('N', strlen($accessToken));
$accessToken = utf8_encode($accessToken);
// The access token in UTF-8 encoding
//$accessToken = mb_convert_encoding($accessToken, 'UTF-8', 'auto');
// Four bytes representing the length, in bytes, of the URL
$urlLength = pack('N', strlen($url));
// The UTF-8 encoded URL converted to uppercase
//$uppercaseURL= mb_strtoupper(mb_convert_encoding($url, 'UTF-8', 'auto'), 'UTF-8');
$uppercaseURL = utf8_encode(strtoupper($url));
// Four bytes representing the size, in bytes, of the WOPI timestamp
// Note: The WOPI timestamp should be converted to a long, so this
// is architecture dependant (here we use PHP_INT_SIZE)
$wopiTimestampSize = pack('N', 8);
// The WOPI timestamp converted to a long (in PHP we use int)
//$wopiTimestamp = (int)$wopiTimeStamp;
$wopiTimestamp = pack('J', $wopiTimeStamp);
return sprintf(
'%s%s%s%s%s%s',
$accessTokenLength,
$accessToken,
$urlLength,
$uppercaseURL,
$wopiTimestampSize,
$wopiTimestamp
);
}
private function verifyKey(string $expected, string $proof, string $proofKey): bool {
try {
/** @var RSA\PublicKey */
$key = PublicKeyLoader::loadPublicKey($proofKey);
} catch (Throwable $e) {
return false;
}
$proof = (string)base64_decode($proof, true);
$key = $key->withHash('sha256');
$key = $key->withPadding(RSA::SIGNATURE_PKCS1);
return $key->verify($expected, $proof);
}
}

30
lib/WOPI/ProofKey.php Normal file
View file

@ -0,0 +1,30 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Richdocuments\WOPI;
class ProofKey {
public function __construct(
private readonly ?string $exponent,
private readonly ?string $modulus,
private readonly ?string $value,
) {
}
public function getExponent(): ?string {
return $this->exponent;
}
public function getModulus(): ?string {
return $this->modulus;
}
public function getValue(): ?string {
return $this->value;
}
}

View file

@ -8,8 +8,11 @@ declare(strict_types=1);
namespace OCA\Richdocuments\Middleware;
use OCA\Richdocuments\Db\WopiMapper;
use OCA\Richdocuments\Service\DiscoveryService;
use OCA\Richdocuments\Service\ProofKeyService;
use OCP\IConfig;
use OCP\IRequest;
use OCP\IURLGenerator;
use Psr\Log\LoggerInterface;
class WOPIMiddlewareTest extends \PHPUnit\Framework\TestCase {
@ -29,6 +32,9 @@ class WOPIMiddlewareTest extends \PHPUnit\Framework\TestCase {
* @var \PHPUnit\Framework\MockObject\MockObject|LoggerInterface|(LoggerInterface&\PHPUnit\Framework\MockObject\MockObject)
*/
private $logger;
private $discoveryService;
private $urlGenerator;
private $proofKeyService;
private WOPIMiddleware $middleware;
public function setUp(): void {
@ -37,11 +43,18 @@ class WOPIMiddlewareTest extends \PHPUnit\Framework\TestCase {
$this->request = $this->createMock(IRequest::class);
$this->wopiMapper = $this->createMock(WopiMapper::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->discoveryService = $this->createMock(DiscoveryService::class);
$this->urlGenerator = $this->createMock(IURLGenerator::class);
$this->proofKeyService = $this->createMock(ProofKeyService::class);
$this->middleware = new WOPIMiddleware(
$this->config,
$this->urlGenerator,
$this->request,
$this->discoveryService,
$this->wopiMapper,
$this->logger,
$this->proofKeyService,
);
}

View file

@ -0,0 +1,112 @@
<?php
declare(strict_types = 1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Tests\Richdocuments;
use OCA\Richdocuments\Service\DiscoveryService;
use OCP\Files\AppData\IAppDataFactory;
use OCP\Http\Client\IClientService;
use OCP\IAppConfig;
use OCP\ICacheFactory;
use OCP\IConfig;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class DiscoveryServiceTest extends TestCase {
private IClientService $clientService;
private ICacheFactory $cacheFactory;
private IAppDataFactory $appDataFactory;
private IAppConfig $appConfig;
private LoggerInterface $logger;
private IConfig $config;
private DiscoveryService $discoveryService;
public function setUp(): void {
parent::setUp();
$this->clientService = $this->createStub(IClientService::class);
$this->cacheFactory = $this->createStub(ICacheFactory::class);
$this->appDataFactory = $this->createStub(IAppDataFactory::class);
$this->appConfig = $this->createStub(IAppConfig::class);
$this->logger = $this->createStub(LoggerInterface::class);
$this->config = $this->createStub(IConfig::class);
$this->discoveryService = $this->getMockBuilder(DiscoveryService::class)
->setConstructorArgs([
$this->clientService,
$this->cacheFactory,
$this->appDataFactory,
$this->appConfig,
$this->logger,
$this->config
])
->onlyMethods(['get'])
->getMock();
}
public function testHasProofKey(): void {
$discoveryXml = <<<END
<wopi-discovery>
<proof-key value="" modulus="" exponent=""/>
</wopi-discovery>
END;
$this->discoveryService->method('get')
->willReturn($discoveryXml);
$this->assertTrue($this->discoveryService->hasProofKey());
}
public function testDoesNotHaveProofKey(): void {
$discoveryXml = <<<END
<wopi-discovery>
</wopi-discovery>
END;
$this->discoveryService->method('get')
->willReturn($discoveryXml);
$this->assertFalse($this->discoveryService->hasProofKey());
}
public function testGetProofKey(): void {
$discoveryXml = <<<END
<wopi-discovery>
<proof-key value="helloworld" modulus="hello" exponent="world"/>
</wopi-discovery>
END;
$this->discoveryService->method('get')
->willReturn($discoveryXml);
$proofKey = $this->discoveryService->getProofKey();
$this->assertEquals('helloworld', $proofKey->getValue());
$this->assertEquals('hello', $proofKey->getModulus());
$this->assertEquals('world', $proofKey->getExponent());
}
public function testGetProofKeyOld(): void {
$discoveryXml = <<<END
<wopi-discovery>
<proof-key oldvalue="helloworld" oldmodulus="hello" oldexponent="world"/>
</wopi-discovery>
END;
$this->discoveryService->method('get')
->willReturn($discoveryXml);
$proofKey = $this->discoveryService->getProofKeyOld();
$this->assertEquals('helloworld', $proofKey->getValue());
$this->assertEquals('hello', $proofKey->getModulus());
$this->assertEquals('world', $proofKey->getExponent());
}
}

View file

@ -0,0 +1,80 @@
<?php
declare(strict_types = 1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Tests\Richdocuments;
use DateTimeImmutable;
use OCA\Richdocuments\Service\DiscoveryService;
use OCA\Richdocuments\Service\ProofKeyService;
use OCP\Files\AppData\IAppDataFactory;
use OCP\Http\Client\IClientService;
use OCP\IAppConfig;
use OCP\ICacheFactory;
use OCP\IConfig;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class ProofKeyServiceTest extends TestCase {
private IClientService $clientService;
private ICacheFactory $cacheFactory;
private IAppDataFactory $appDataFactory;
private IAppConfig $appConfig;
private LoggerInterface $logger;
private IConfig $config;
private DiscoveryService $discoveryService;
private ProofKeyService $proofKeyService;
public function setUp(): void {
parent::setUp();
$this->clientService = $this->createStub(IClientService::class);
$this->cacheFactory = $this->createStub(ICacheFactory::class);
$this->appDataFactory = $this->createStub(IAppDataFactory::class);
$this->appConfig = $this->createStub(IAppConfig::class);
$this->logger = $this->createStub(LoggerInterface::class);
$this->config = $this->createStub(IConfig::class);
$this->discoveryService = $this->getMockBuilder(DiscoveryService::class)
->setConstructorArgs([
$this->clientService,
$this->cacheFactory,
$this->appDataFactory,
$this->appConfig,
$this->logger,
$this->config
])
->onlyMethods(['get'])
->getMock();
$this->proofKeyService = new ProofKeyService($this->discoveryService);
}
public function testWindowsToUnixTimestamp(): void {
// Timestamps representing 15 February, 2024 00:00:00
$windowsTimestamp = '133524468000000000';
$expectedUnixTimestamp = '1707973200';
$unixTimestamp = $this->proofKeyService->windowsToUnixTimestamp($windowsTimestamp);
$this->assertEquals($expectedUnixTimestamp, $unixTimestamp);
}
public function testIsOldTimestamp(): void {
$now = new DateTimeImmutable();
$validAge = $now->modify('-10 minutes');
$isOld = $this->proofKeyService->isOldTimestamp($validAge->getTimestamp());
$this->assertFalse($isOld);
$invalidAge = $now->modify('-30 minutes');
$isOld = $this->proofKeyService->isOldTimestamp($invalidAge->getTimestamp());
$this->assertTrue($isOld);
}
}