mirror of
https://github.com/nextcloud/richdocuments.git
synced 2025-12-18 05:20:43 +01:00
feat(wopi): support for wopi proof key
Signed-off-by: Elizabeth Danzberger <lizzy7128@tutanota.de>
This commit is contained in:
parent
e8696ff5ea
commit
7471c8f7f7
10 changed files with 738 additions and 4 deletions
|
|
@ -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
229
composer.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
9
lib/Exceptions/WopiException.php
Normal file
9
lib/Exceptions/WopiException.php
Normal 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 {
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
147
lib/Service/ProofKeyService.php
Normal file
147
lib/Service/ProofKeyService.php
Normal 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
30
lib/WOPI/ProofKey.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
112
tests/lib/Service/DiscoveryServiceTest.php
Normal file
112
tests/lib/Service/DiscoveryServiceTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
80
tests/lib/Service/ProofKeyServiceTest.php
Normal file
80
tests/lib/Service/ProofKeyServiceTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue