aboutsummaryrefslogtreecommitdiffstats
path: root/lib/private/Security/Signature/Model/IncomingSignedRequest.php
blob: 0f7dc7cb771544454a6ff5f9e11b101185924c24 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
<?php

declare(strict_types=1);

/**
 * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
 * SPDX-License-Identifier: AGPL-3.0-or-later
 */
namespace OC\Security\Signature\Model;

use JsonSerializable;
use NCU\Security\Signature\Enum\DigestAlgorithm;
use NCU\Security\Signature\Enum\SignatureAlgorithm;
use NCU\Security\Signature\Exceptions\IdentityNotFoundException;
use NCU\Security\Signature\Exceptions\IncomingRequestException;
use NCU\Security\Signature\Exceptions\InvalidSignatureException;
use NCU\Security\Signature\Exceptions\SignatoryNotFoundException;
use NCU\Security\Signature\Exceptions\SignatureElementNotFoundException;
use NCU\Security\Signature\Exceptions\SignatureException;
use NCU\Security\Signature\Exceptions\SignatureNotFoundException;
use NCU\Security\Signature\IIncomingSignedRequest;
use NCU\Security\Signature\ISignatureManager;
use NCU\Security\Signature\Model\Signatory;
use OC\Security\Signature\SignatureManager;
use OCP\IRequest;
use ValueError;

/**
 * @inheritDoc
 *
 * @see ISignatureManager for details on signature
 * @since 31.0.0
 */
class IncomingSignedRequest extends SignedRequest implements
	IIncomingSignedRequest,
	JsonSerializable {
	private string $origin = '';

	/**
	 * @param string $body
	 * @param IRequest $request
	 * @param array $options
	 *
	 * @throws IncomingRequestException if incoming request is wrongly signed
	 * @throws SignatureException if signature is faulty
	 * @throws SignatureNotFoundException if signature is not implemented
	 */
	public function __construct(
		string $body,
		private readonly IRequest $request,
		private readonly array $options = [],
	) {
		parent::__construct($body);
		$this->verifyHeaders();
		$this->extractSignatureHeader();
		$this->reconstructSignatureData();

		try {
			// we set origin based on the keyId defined in the Signature header of the request
			$this->setOrigin(Signatory::extractIdentityFromUri($this->getSigningElement('keyId')));
		} catch (IdentityNotFoundException $e) {
			throw new IncomingRequestException($e->getMessage());
		}
	}

	/**
	 * confirm that:
	 *
	 * - date is available in the header and its value is less than 5 minutes old
	 * - content-length is available and is the same as the payload size
	 * - digest is available and fit the checksum of the payload
	 *
	 * @throws IncomingRequestException
	 * @throws SignatureNotFoundException
	 */
	private function verifyHeaders(): void {
		if ($this->request->getHeader('Signature') === '') {
			throw new SignatureNotFoundException('missing Signature in header');
		}

		// confirm presence of date, content-length, digest and Signature
		$date = $this->request->getHeader('date');
		if ($date === '') {
			throw new IncomingRequestException('missing date in header');
		}
		$contentLength = $this->request->getHeader('content-length');
		if ($contentLength === '') {
			throw new IncomingRequestException('missing content-length in header');
		}
		$digest = $this->request->getHeader('digest');
		if ($digest === '') {
			throw new IncomingRequestException('missing digest in header');
		}

		// confirm date
		try {
			$dTime = new \DateTime($date);
			$requestTime = $dTime->getTimestamp();
		} catch (\Exception) {
			throw new IncomingRequestException('datetime exception');
		}
		if ($requestTime < (time() - ($this->options['ttl'] ?? SignatureManager::DATE_TTL))) {
			throw new IncomingRequestException('object is too old');
		}

		// confirm validity of content-length
		if (strlen($this->getBody()) !== (int)$contentLength) {
			throw new IncomingRequestException('inexact content-length in header');
		}

		// confirm digest value, based on body
		[$algo, ] = explode('=', $digest);
		try {
			$this->setDigestAlgorithm(DigestAlgorithm::from($algo));
		} catch (ValueError) {
			throw new IncomingRequestException('unknown digest algorithm');
		}
		if ($digest !== $this->getDigest()) {
			throw new IncomingRequestException('invalid value for digest in header');
		}
	}

	/**
	 * extract data from the header entry 'Signature' and convert its content from string to an array
	 * also confirm that it contains the minimum mandatory information
	 *
	 * @throws IncomingRequestException
	 */
	private function extractSignatureHeader(): void {
		$details = [];
		foreach (explode(',', $this->request->getHeader('Signature')) as $entry) {
			if ($entry === '' || !strpos($entry, '=')) {
				continue;
			}

			[$k, $v] = explode('=', $entry, 2);
			preg_match('/^"([^"]+)"$/', $v, $var);
			if ($var[0] !== '') {
				$v = trim($var[0], '"');
			}
			$details[$k] = $v;
		}

		$this->setSigningElements($details);

		try {
			// confirm keys are in the Signature header
			$this->getSigningElement('keyId');
			$this->getSigningElement('headers');
			$this->setSignature($this->getSigningElement('signature'));
		} catch (SignatureElementNotFoundException $e) {
			throw new IncomingRequestException($e->getMessage());
		}
	}

	/**
	 * reconstruct signature data based on signature's metadata stored in the 'Signature' header
	 *
	 * @throws SignatureException
	 * @throws SignatureElementNotFoundException
	 */
	private function reconstructSignatureData(): void {
		$usedHeaders = explode(' ', $this->getSigningElement('headers'));
		$neededHeaders = array_merge(['date', 'host', 'content-length', 'digest'],
			array_keys($this->options['extraSignatureHeaders'] ?? []));

		$missingHeaders = array_diff($neededHeaders, $usedHeaders);
		if ($missingHeaders !== []) {
			throw new SignatureException('missing entries in Signature.headers: ' . json_encode($missingHeaders));
		}

		$estimated = ['(request-target): ' . strtolower($this->request->getMethod()) . ' ' . $this->request->getRequestUri()];
		foreach ($usedHeaders as $key) {
			if ($key === '(request-target)') {
				continue;
			}
			$value = (strtolower($key) === 'host') ? $this->request->getServerHost() : $this->request->getHeader($key);
			if ($value === '') {
				throw new SignatureException('missing header ' . $key . ' in request');
			}

			$estimated[] = $key . ': ' . $value;
		}

		$this->setSignatureData($estimated);
	}

	/**
	 * @inheritDoc
	 *
	 * @return IRequest
	 * @since 31.0.0
	 */
	public function getRequest(): IRequest {
		return $this->request;
	}

	/**
	 * set the hostname at the source of the request,
	 * based on the keyId defined in the signature header.
	 *
	 * @param string $origin
	 * @since 31.0.0
	 */
	private function setOrigin(string $origin): void {
		$this->origin = $origin;
	}

	/**
	 * @inheritDoc
	 *
	 * @return string
	 * @throws IncomingRequestException
	 * @since 31.0.0
	 */
	public function getOrigin(): string {
		if ($this->origin === '') {
			throw new IncomingRequestException('empty origin');
		}
		return $this->origin;
	}

	/**
	 * returns the keyId extracted from the signature headers.
	 * keyId is a mandatory entry in the headers of a signed request.
	 *
	 * @return string
	 * @throws SignatureElementNotFoundException
	 * @since 31.0.0
	 */
	public function getKeyId(): string {
		return $this->getSigningElement('keyId');
	}

	/**
	 * @inheritDoc
	 *
	 * @throws SignatureException
	 * @throws SignatoryNotFoundException
	 * @since 31.0.0
	 */
	public function verify(): void {
		$publicKey = $this->getSignatory()->getPublicKey();
		if ($publicKey === '') {
			throw new SignatoryNotFoundException('empty public key');
		}

		$algorithm = SignatureAlgorithm::tryFrom($this->getSigningElement('algorithm')) ?? SignatureAlgorithm::RSA_SHA256;
		if (openssl_verify(
			implode("\n", $this->getSignatureData()),
			base64_decode($this->getSignature()),
			$publicKey,
			$algorithm->value
		) !== 1) {
			throw new InvalidSignatureException('signature issue');
		}
	}

	public function jsonSerialize(): array {
		return array_merge(
			parent::jsonSerialize(),
			[
				'options' => $this->options,
				'origin' => $this->origin,
			]
		);
	}
}