mirror of
https://github.com/nextcloud/server.git
synced 2026-05-28 04:32:30 -04:00
feat(http-sig): RFC 9421 protocol primitives
Add the RFC 9421 (HTTP Message Signatures) sign/verify path alongside the existing draft-cavage implementation: - Algorithm: sodium for Ed25519, JWT::sign for RSA / ECDSA, ecdsaRawToDer for the ECDSA wire format. JWK parsing via JWK::parseKey. - SignatureBase: RFC 9421 §2.5 base construction for the derived components OCM uses plus plain HTTP fields. - ContentDigest: RFC 9530 helpers used as a covered component. - Rfc9421IncomingSignedRequest / Rfc9421OutgoingSignedRequest: request models. Parsing of Signature-Input / Signature delegates to gapple\\StructuredFields\\Parser. - IJwkResolvingSignatoryManager: capability bit signatory managers advertise to participate in RFC 9421 verification. - OcmProfile: OCM-mandated dictionary label. - SignatureManager: dispatch to RFC 9421 inbound when Signature-Input is present, outbound when rfc9421.format is set. Plus tests for each primitive and a full round-trip across the model. Signed-off-by: Micke Nordin <kano@sunet.se>
This commit is contained in:
parent
ea9bbe64c1
commit
0eb927e617
12 changed files with 2120 additions and 7 deletions
|
|
@ -0,0 +1,465 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OC\Security\Signature\Model;
|
||||
|
||||
use Firebase\JWT\Key;
|
||||
use gapple\StructuredFields\Bytes;
|
||||
use gapple\StructuredFields\InnerList;
|
||||
use gapple\StructuredFields\Item;
|
||||
use gapple\StructuredFields\Parameters;
|
||||
use gapple\StructuredFields\ParseException;
|
||||
use gapple\StructuredFields\Parser;
|
||||
use gapple\StructuredFields\Token;
|
||||
use JsonSerializable;
|
||||
use OC\Security\Signature\Rfc9421\Algorithm;
|
||||
use OC\Security\Signature\Rfc9421\ContentDigest;
|
||||
use OC\Security\Signature\Rfc9421\SignatureBase;
|
||||
use OC\Security\Signature\SignatureManager;
|
||||
use OCP\IRequest;
|
||||
use OCP\Security\Signature\Exceptions\IdentityNotFoundException;
|
||||
use OCP\Security\Signature\Exceptions\IncomingRequestException;
|
||||
use OCP\Security\Signature\Exceptions\InvalidSignatureException;
|
||||
use OCP\Security\Signature\Exceptions\SignatoryNotFoundException;
|
||||
use OCP\Security\Signature\Exceptions\SignatureException;
|
||||
use OCP\Security\Signature\Exceptions\SignatureNotFoundException;
|
||||
use OCP\Security\Signature\IIncomingSignedRequest;
|
||||
use OCP\Security\Signature\Model\Signatory;
|
||||
|
||||
/**
|
||||
* RFC 9421 implementation of {@see IIncomingSignedRequest}. Parses the
|
||||
* inbound Signature-Input / Signature dictionaries, picks the OCM-labeled
|
||||
* entry (RFC 9421 §3.2 lets verifiers scope by policy), and rebuilds the
|
||||
* signature base per RFC 9421 §2.5. Crypto is deferred to {@see verify()},
|
||||
* which needs a {@see Key} attached via {@see setKey()}. Body integrity
|
||||
* (RFC 9530 content-digest) is checked before verify() if covered.
|
||||
*/
|
||||
class Rfc9421IncomingSignedRequest extends SignedRequest implements
|
||||
IIncomingSignedRequest,
|
||||
JsonSerializable {
|
||||
/** Baseline cover for OCM. Override via `rfc9421.requiredComponents`. */
|
||||
private const DEFAULT_REQUIRED_COMPONENTS = [
|
||||
'@method',
|
||||
'@target-uri',
|
||||
'content-digest',
|
||||
'content-length',
|
||||
'date',
|
||||
];
|
||||
|
||||
/** Max clock skew (seconds) for `created`. Override via `rfc9421.maxClockSkew`. */
|
||||
private const DEFAULT_MAX_FUTURE_SKEW = 60;
|
||||
|
||||
private string $origin = '';
|
||||
/** @var list<string> */
|
||||
private array $components;
|
||||
/** @var array<string, scalar> */
|
||||
private array $signatureParams;
|
||||
private string $signatureBaseString;
|
||||
private string $rawSignature;
|
||||
private ?Key $key = null;
|
||||
|
||||
/**
|
||||
* @throws IncomingRequestException if anything looks wrong with the request structure
|
||||
* @throws SignatureNotFoundException if the request is not signed
|
||||
* @throws SignatureException if signature metadata is malformed or covered components reference missing fields
|
||||
*/
|
||||
public function __construct(
|
||||
string $body,
|
||||
private readonly IRequest $request,
|
||||
private readonly array $options = [],
|
||||
) {
|
||||
parent::__construct($body);
|
||||
|
||||
$signatureInputHeader = $request->getHeader('Signature-Input');
|
||||
$signatureHeader = $request->getHeader('Signature');
|
||||
if ($signatureInputHeader === '') {
|
||||
throw new SignatureNotFoundException('missing Signature-Input header');
|
||||
}
|
||||
if ($signatureHeader === '') {
|
||||
throw new SignatureNotFoundException('missing Signature header');
|
||||
}
|
||||
|
||||
$inputs = self::parseSignatureInput($signatureInputHeader);
|
||||
$signatures = self::parseSignature($signatureHeader);
|
||||
|
||||
// OCM policy (stricter than RFC 8941 §4.2 last-wins): a duplicate
|
||||
// `ocm` entry is ambiguous; the entire request MUST be rejected.
|
||||
if (self::countLabel($signatureInputHeader, 'ocm') > 1
|
||||
|| self::countLabel($signatureHeader, 'ocm') > 1) {
|
||||
throw new IncomingRequestException(
|
||||
'multiple "' . 'ocm' . '" entries in signature headers'
|
||||
);
|
||||
}
|
||||
|
||||
if (!isset($inputs['ocm'])) {
|
||||
throw new SignatureNotFoundException('missing "' . 'ocm' . '" entry in Signature-Input');
|
||||
}
|
||||
if (!isset($signatures['ocm'])) {
|
||||
throw new SignatureNotFoundException('missing "' . 'ocm' . '" entry in Signature');
|
||||
}
|
||||
|
||||
$entry = $inputs['ocm'];
|
||||
$this->components = $entry['components'];
|
||||
$this->signatureParams = $entry['params'];
|
||||
$this->rawSignature = $signatures['ocm'];
|
||||
|
||||
$this->verifyRequiredComponents();
|
||||
$this->verifyTimestamps();
|
||||
$this->verifyContentDigestIfCovered($body);
|
||||
$this->verifyContentLengthIfCovered($body);
|
||||
|
||||
$keyId = $this->signatureParams['keyid'] ?? null;
|
||||
if (!is_string($keyId) || $keyId === '') {
|
||||
throw new IncomingRequestException('missing keyid in Signature-Input');
|
||||
}
|
||||
try {
|
||||
$this->origin = Signatory::extractIdentityFromUri($keyId);
|
||||
} catch (IdentityNotFoundException) {
|
||||
// keyid may follow the OCM convention `<fqdn>#<id>`; the OCM layer
|
||||
// derives origin from the message body in that case.
|
||||
$this->origin = '';
|
||||
}
|
||||
|
||||
$paramsLine = SignatureBase::serializeSignatureParams($this->components, $this->signatureParams);
|
||||
$this->signatureBaseString = SignatureBase::build(
|
||||
$request->getMethod(),
|
||||
$this->reconstructTargetUri(),
|
||||
$this->collectHeaders(),
|
||||
$this->components,
|
||||
$paramsLine,
|
||||
);
|
||||
|
||||
$this->setSigningElements([
|
||||
'label' => 'ocm',
|
||||
'keyId' => $keyId,
|
||||
'algorithm' => isset($this->signatureParams['alg']) ? (string)$this->signatureParams['alg'] : '',
|
||||
'created' => isset($this->signatureParams['created']) ? (string)$this->signatureParams['created'] : '',
|
||||
'components' => implode(' ', $this->components),
|
||||
'params' => $paramsLine,
|
||||
'signature' => base64_encode($this->rawSignature),
|
||||
]);
|
||||
$this->setSignature(base64_encode($this->rawSignature));
|
||||
$this->setSignatureData([$this->signatureBaseString]);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getRequest(): IRequest {
|
||||
return $this->request;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getOrigin(): string {
|
||||
if ($this->origin === '') {
|
||||
throw new IncomingRequestException('empty origin');
|
||||
}
|
||||
return $this->origin;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getKeyId(): string {
|
||||
return $this->getSigningElement('keyId');
|
||||
}
|
||||
|
||||
/** Required before {@see verify()} is called. */
|
||||
public function setKey(Key $key): self {
|
||||
$this->key = $key;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getKey(): ?Key {
|
||||
return $this->key;
|
||||
}
|
||||
|
||||
/** Signature-Input `alg` if present, else null (RFC 9421 §3.3.7 omitted-alg path). */
|
||||
public function getAlgorithm(): ?string {
|
||||
return isset($this->signatureParams['alg']) ? (string)$this->signatureParams['alg'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, scalar>
|
||||
*/
|
||||
public function getSignatureParams(): array {
|
||||
return $this->signatureParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getCoveredComponents(): array {
|
||||
return $this->components;
|
||||
}
|
||||
|
||||
public function getSignatureBaseString(): string {
|
||||
return $this->signatureBaseString;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function verify(): void {
|
||||
if ($this->key === null) {
|
||||
throw new SignatoryNotFoundException('no JWK set for verification');
|
||||
}
|
||||
try {
|
||||
$ok = Algorithm::verify(
|
||||
$this->signatureBaseString,
|
||||
$this->rawSignature,
|
||||
$this->key,
|
||||
$this->getAlgorithm(),
|
||||
);
|
||||
} catch (SignatureException $e) {
|
||||
throw new InvalidSignatureException($e->getMessage(), 0, $e);
|
||||
}
|
||||
if (!$ok) {
|
||||
throw new InvalidSignatureException('signature verification failed');
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws IncomingRequestException if the signature doesn't cover the OCM-required components */
|
||||
private function verifyRequiredComponents(): void {
|
||||
/** @var list<string> $required */
|
||||
$required = $this->options['rfc9421.requiredComponents'] ?? self::DEFAULT_REQUIRED_COMPONENTS;
|
||||
$missing = array_values(array_diff($required, $this->components));
|
||||
if ($missing !== []) {
|
||||
throw new IncomingRequestException(
|
||||
'signature does not cover required components: ' . implode(', ', $missing)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws IncomingRequestException on stale, future-dated, or missing `created` */
|
||||
private function verifyTimestamps(): void {
|
||||
$ttl = (int)($this->options['ttl'] ?? SignatureManager::DATE_TTL);
|
||||
$skew = (int)($this->options['rfc9421.maxClockSkew'] ?? self::DEFAULT_MAX_FUTURE_SKEW);
|
||||
$now = time();
|
||||
|
||||
if (!isset($this->signatureParams['created'])) {
|
||||
throw new IncomingRequestException('signature missing required `created` parameter');
|
||||
}
|
||||
$created = (int)$this->signatureParams['created'];
|
||||
if ($created > $now + $skew) {
|
||||
throw new IncomingRequestException('signature `created` is too far in the future');
|
||||
}
|
||||
if ($ttl > 0 && $created < $now - $ttl) {
|
||||
throw new IncomingRequestException('signature is too old');
|
||||
}
|
||||
|
||||
if (isset($this->signatureParams['expires'])) {
|
||||
$expires = (int)$this->signatureParams['expires'];
|
||||
if ($expires < $now) {
|
||||
throw new IncomingRequestException('signature has expired');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function verifyContentDigestIfCovered(string $body): void {
|
||||
if (!in_array('content-digest', $this->components, true)) {
|
||||
return;
|
||||
}
|
||||
$header = $this->request->getHeader('Content-Digest');
|
||||
if ($header === '') {
|
||||
throw new IncomingRequestException('content-digest covered but missing from request');
|
||||
}
|
||||
if (!ContentDigest::verify($header, $body)) {
|
||||
throw new IncomingRequestException('content-digest does not match body');
|
||||
}
|
||||
}
|
||||
|
||||
private function verifyContentLengthIfCovered(string $body): void {
|
||||
if (!in_array('content-length', $this->components, true)) {
|
||||
return;
|
||||
}
|
||||
$header = $this->request->getHeader('Content-Length');
|
||||
if ($header === '') {
|
||||
throw new IncomingRequestException('content-length covered but missing from request');
|
||||
}
|
||||
if ((int)$header !== strlen($body)) {
|
||||
throw new IncomingRequestException('content-length does not match body size');
|
||||
}
|
||||
}
|
||||
|
||||
private function reconstructTargetUri(): string {
|
||||
$scheme = $this->request->getServerProtocol();
|
||||
$host = $this->request->getServerHost();
|
||||
$path = $this->request->getRequestUri();
|
||||
return $scheme . '://' . $host . $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the HTTP request fields covered by the signature, keyed by their
|
||||
* lowercased name. Derived components (`@*`) are produced inside
|
||||
* {@see SignatureBase}; we only collect plain fields here.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function collectHeaders(): array {
|
||||
$out = [];
|
||||
foreach ($this->components as $component) {
|
||||
if (str_starts_with($component, '@')) {
|
||||
continue;
|
||||
}
|
||||
$value = $this->request->getHeader($component);
|
||||
if ($value === '' && strtolower($component) === 'host') {
|
||||
$value = $this->request->getServerHost();
|
||||
}
|
||||
$out[strtolower($component)] = $value;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function jsonSerialize(): array {
|
||||
return array_merge(
|
||||
parent::jsonSerialize(),
|
||||
[
|
||||
'origin' => $this->origin,
|
||||
'label' => 'ocm',
|
||||
'components' => $this->components,
|
||||
'signatureParams' => $this->signatureParams,
|
||||
'signatureBase' => $this->signatureBaseString,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{components: list<string>, params: array<string, scalar>}>
|
||||
* @throws SignatureException
|
||||
*/
|
||||
private static function parseSignatureInput(string $header): array {
|
||||
try {
|
||||
$dict = Parser::parseDictionary($header);
|
||||
} catch (ParseException $e) {
|
||||
throw new SignatureException('malformed Signature-Input: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($dict as $label => $entry) {
|
||||
if (!$entry instanceof InnerList) {
|
||||
throw new SignatureException('Signature-Input value for ' . $label . ' is not an inner list');
|
||||
}
|
||||
$components = [];
|
||||
foreach ($entry->getValue() as $item) {
|
||||
$value = $item->getValue();
|
||||
if (!is_string($value)) {
|
||||
throw new SignatureException('component identifier in Signature-Input must be a string');
|
||||
}
|
||||
$components[] = $value;
|
||||
}
|
||||
$parameters = $entry->getParameters();
|
||||
if (!$parameters instanceof Parameters) {
|
||||
throw new SignatureException('Signature-Input parameters for ' . $label . ' are not iterable');
|
||||
}
|
||||
$out[$label] = [
|
||||
'components' => $components,
|
||||
'params' => self::normalizeParameters($parameters),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string> raw signature bytes keyed by label
|
||||
* @throws SignatureException
|
||||
*/
|
||||
private static function parseSignature(string $header): array {
|
||||
try {
|
||||
$dict = Parser::parseDictionary($header);
|
||||
} catch (ParseException $e) {
|
||||
throw new SignatureException('malformed Signature: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($dict as $label => $entry) {
|
||||
if (!$entry instanceof Item || !$entry->getValue() instanceof Bytes) {
|
||||
throw new SignatureException('Signature value for ' . $label . ' is not a byte sequence');
|
||||
}
|
||||
$out[$label] = (string)$entry->getValue();
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<string, mixed> $parameters
|
||||
* @return array<string, scalar>
|
||||
*/
|
||||
private static function normalizeParameters(iterable $parameters): array {
|
||||
$out = [];
|
||||
foreach ($parameters as $name => $value) {
|
||||
$out[(string)$name] = match (true) {
|
||||
is_string($value), is_int($value), is_bool($value) => $value,
|
||||
$value instanceof Token => (string)$value,
|
||||
default => throw new SignatureException('unsupported parameter type for ' . $name),
|
||||
};
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Count $label occurrences in a dictionary header (gapple collapses dups per RFC 8941 §4.2). */
|
||||
private static function countLabel(string $header, string $label): int {
|
||||
$count = 0;
|
||||
$len = strlen($header);
|
||||
$i = 0;
|
||||
while ($i < $len) {
|
||||
while ($i < $len && ($header[$i] === ' ' || $header[$i] === "\t")) {
|
||||
$i++;
|
||||
}
|
||||
$start = $i;
|
||||
while ($i < $len) {
|
||||
$c = $header[$i];
|
||||
if (!ctype_lower($c) && !ctype_digit($c) && $c !== '*' && $c !== '_' && $c !== '-' && $c !== '.') {
|
||||
break;
|
||||
}
|
||||
$i++;
|
||||
}
|
||||
if ($i === $start) {
|
||||
break;
|
||||
}
|
||||
if (substr($header, $start, $i - $start) === $label) {
|
||||
$count++;
|
||||
}
|
||||
// Skip to next top-level comma; track strings, byte-sequences, parens.
|
||||
$inString = false;
|
||||
$inByteSeq = false;
|
||||
$depth = 0;
|
||||
while ($i < $len) {
|
||||
$c = $header[$i];
|
||||
if ($inString) {
|
||||
if ($c === '\\' && $i + 1 < $len) {
|
||||
$i += 2;
|
||||
continue;
|
||||
}
|
||||
if ($c === '"') {
|
||||
$inString = false;
|
||||
}
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
if ($inByteSeq) {
|
||||
if ($c === ':') {
|
||||
$inByteSeq = false;
|
||||
}
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
if ($c === '"') {
|
||||
$inString = true;
|
||||
} elseif ($c === ':') {
|
||||
$inByteSeq = true;
|
||||
} elseif ($c === '(') {
|
||||
$depth++;
|
||||
} elseif ($c === ')') {
|
||||
$depth--;
|
||||
} elseif ($c === ',' && $depth === 0) {
|
||||
$i++;
|
||||
break;
|
||||
}
|
||||
$i++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OC\Security\Signature\Model;
|
||||
|
||||
use JsonSerializable;
|
||||
use OC\Security\Signature\Rfc9421\Algorithm;
|
||||
use OC\Security\Signature\Rfc9421\ContentDigest;
|
||||
use OC\Security\Signature\Rfc9421\SignatureBase;
|
||||
use OC\Security\Signature\SignatureManager;
|
||||
use OCP\Security\Signature\Enum\DigestAlgorithm;
|
||||
use OCP\Security\Signature\Enum\SignatureAlgorithm;
|
||||
use OCP\Security\Signature\Exceptions\SignatoryException;
|
||||
use OCP\Security\Signature\Exceptions\SignatoryNotFoundException;
|
||||
use OCP\Security\Signature\IOutgoingSignedRequest;
|
||||
use OCP\Security\Signature\ISignatoryManager;
|
||||
|
||||
/**
|
||||
* RFC 9421 implementation of {@see IOutgoingSignedRequest}, sibling to the
|
||||
* draft-cavage {@see OutgoingSignedRequest}. Default Ed25519 with the `alg`
|
||||
* parameter omitted (RFC 9421 §3.3.7); verifier resolves it from the JWK.
|
||||
*
|
||||
* Options from {@see ISignatoryManager::getOptions()}: `rfc9421.signingAlgorithm`,
|
||||
* `rfc9421.coveredComponents`, `rfc9421.contentDigestAlgorithm`,
|
||||
* `rfc9421.includeAlgParameter`, `dateHeader`.
|
||||
*/
|
||||
class Rfc9421OutgoingSignedRequest extends SignedRequest implements
|
||||
IOutgoingSignedRequest,
|
||||
JsonSerializable {
|
||||
private const DEFAULT_COMPONENTS = ['@method', '@target-uri', 'content-digest', 'content-length', 'date'];
|
||||
|
||||
private string $host = '';
|
||||
private array $headers = [];
|
||||
/** @var list<string> $headerList */
|
||||
private array $headerList = [];
|
||||
private SignatureAlgorithm $algorithm;
|
||||
private string $signingAlgorithm;
|
||||
/** @var array<string, scalar> */
|
||||
private array $signatureParams;
|
||||
private string $signatureBaseString;
|
||||
|
||||
public function __construct(
|
||||
string $body,
|
||||
ISignatoryManager $signatoryManager,
|
||||
private readonly string $identity,
|
||||
private readonly string $method,
|
||||
private readonly string $uri,
|
||||
) {
|
||||
parent::__construct($body);
|
||||
|
||||
$options = $signatoryManager->getOptions();
|
||||
$this->setHost($identity)
|
||||
->setAlgorithm($options['algorithm'] ?? SignatureAlgorithm::RSA_SHA256)
|
||||
->setSignatory($signatoryManager->getLocalSignatory())
|
||||
->setDigestAlgorithm($options['digestAlgorithm'] ?? DigestAlgorithm::SHA256);
|
||||
|
||||
$this->signingAlgorithm = (string)($options['rfc9421.signingAlgorithm'] ?? 'ed25519');
|
||||
$contentDigestAlgorithm = (string)($options['rfc9421.contentDigestAlgorithm'] ?? ContentDigest::ALGO_SHA256);
|
||||
/** @var list<string> $components */
|
||||
$components = $options['rfc9421.coveredComponents'] ?? self::DEFAULT_COMPONENTS;
|
||||
$includeAlg = (bool)($options['rfc9421.includeAlgParameter'] ?? false);
|
||||
$dateHeaderFormat = (string)($options['dateHeader'] ?? SignatureManager::DATE_HEADER);
|
||||
|
||||
$this->addHeader('Content-Digest', ContentDigest::compute($body, $contentDigestAlgorithm))
|
||||
->addHeader('Content-Length', strlen($body))
|
||||
->addHeader('Date', gmdate($dateHeaderFormat));
|
||||
if (in_array('host', $components, true)) {
|
||||
$this->addHeader('Host', $this->host);
|
||||
}
|
||||
|
||||
$this->setHeaderList($components);
|
||||
$this->signatureParams = [
|
||||
'created' => time(),
|
||||
'keyid' => $this->getSignatory()->getKeyId(),
|
||||
];
|
||||
if ($includeAlg) {
|
||||
// Off by default per RFC 9421 §3.3.7 (verifier resolves alg from JWK).
|
||||
$this->signatureParams['alg'] = $this->signingAlgorithm;
|
||||
}
|
||||
|
||||
$this->signatureBaseString = SignatureBase::build(
|
||||
$this->method,
|
||||
$this->uri,
|
||||
$this->headersByLowercaseName(),
|
||||
$this->headerList,
|
||||
SignatureBase::serializeSignatureParams($this->headerList, $this->signatureParams)
|
||||
);
|
||||
$this->setSignatureData([$this->signatureBaseString]);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function setHost(string $host): self {
|
||||
$this->host = $host;
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getHost(): string {
|
||||
return $this->host;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function addHeader(string $key, string|int|float $value): self {
|
||||
$this->headers[$key] = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getHeaders(): array {
|
||||
return $this->headers;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function setHeaderList(array $list): self {
|
||||
$this->headerList = $list;
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getHeaderList(): array {
|
||||
return $this->headerList;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function setAlgorithm(SignatureAlgorithm $algorithm): self {
|
||||
$this->algorithm = $algorithm;
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getAlgorithm(): SignatureAlgorithm {
|
||||
return $this->algorithm;
|
||||
}
|
||||
|
||||
/** RFC 9421 alg name (e.g. `ed25519`). Distinct from cavage's {@see getAlgorithm()}. */
|
||||
public function getSigningAlgorithm(): string {
|
||||
return $this->signingAlgorithm;
|
||||
}
|
||||
|
||||
public function getSignatureBaseString(): string {
|
||||
return $this->signatureBaseString;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function sign(): self {
|
||||
$privateKey = $this->getSignatory()->getPrivateKey();
|
||||
if ($privateKey === '') {
|
||||
throw new SignatoryException('empty private key');
|
||||
}
|
||||
|
||||
$rawSignature = Algorithm::sign(
|
||||
$this->signatureBaseString,
|
||||
$privateKey,
|
||||
$this->signingAlgorithm,
|
||||
);
|
||||
$this->setSignature(base64_encode($rawSignature));
|
||||
|
||||
$paramsLine = SignatureBase::serializeSignatureParams($this->headerList, $this->signatureParams);
|
||||
$this->addHeader('Signature-Input', 'ocm=' . $paramsLine);
|
||||
$this->addHeader('Signature', 'ocm=:' . base64_encode($rawSignature) . ':');
|
||||
|
||||
$this->setSigningElements([
|
||||
'label' => 'ocm',
|
||||
'components' => implode(' ', $this->headerList),
|
||||
'params' => $paramsLine,
|
||||
'signature' => $this->getSignature(),
|
||||
]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function headersByLowercaseName(): array {
|
||||
$out = [];
|
||||
foreach ($this->headers as $name => $value) {
|
||||
$out[strtolower($name)] = (string)$value;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws SignatoryNotFoundException
|
||||
*/
|
||||
#[\Override]
|
||||
public function jsonSerialize(): array {
|
||||
return array_merge(
|
||||
parent::jsonSerialize(),
|
||||
[
|
||||
'host' => $this->host,
|
||||
'headers' => $this->headers,
|
||||
'algorithm' => $this->algorithm->value,
|
||||
'signingAlgorithm' => $this->signingAlgorithm,
|
||||
'method' => $this->method,
|
||||
'identity' => $this->identity,
|
||||
'uri' => $this->uri,
|
||||
'components' => $this->headerList,
|
||||
'signatureBase' => $this->signatureBaseString,
|
||||
'signatureParams' => $this->signatureParams,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
221
lib/private/Security/Signature/Rfc9421/Algorithm.php
Normal file
221
lib/private/Security/Signature/Rfc9421/Algorithm.php
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OC\Security\Signature\Rfc9421;
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use InvalidArgumentException;
|
||||
use OCP\Security\Signature\Exceptions\SignatureException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* RFC 9421 §3.3 sign/verify primitives.
|
||||
*
|
||||
* Asymmetric algorithms only: RSA-PKCS1-v1_5 (SHA-256/384/512), ECDSA P-256
|
||||
* SHA-256, ECDSA P-384 SHA-384, Ed25519. JOSE aliases (RFC 7518 / RFC 8037)
|
||||
* accepted per RFC 9421 §3.3.7. RSA-PSS is rejected: OPENSSL_PKCS1_PSS_PADDING
|
||||
* needs PHP 8.5 and we still support 8.2-8.4.
|
||||
*
|
||||
* Sign delegates to {@see JWT::sign}. Verify takes a {@see Key} parsed by
|
||||
* firebase/php-jwt (which has already validated the JWK's kty/crv/alg
|
||||
* consistency) and only enforces the cross-source agreement between the JWK
|
||||
* `alg` and the Signature-Input `alg` parameter (RFC 9421 §3.2 step 6).
|
||||
*/
|
||||
final class Algorithm {
|
||||
public const NATIVE = [
|
||||
'rsa-v1_5-sha256',
|
||||
'ecdsa-p256-sha256',
|
||||
'ecdsa-p384-sha384',
|
||||
'ed25519',
|
||||
];
|
||||
|
||||
/**
|
||||
* For Ed25519 $privateKey is the raw 64-byte sodium secret key; otherwise
|
||||
* a PEM private key. Returns raw signature bytes (R||S for ECDSA).
|
||||
*
|
||||
* Ed25519 calls sodium directly: JWT::sign runs the key through
|
||||
* `validateEdDSAKey` which base64url-decodes it first, which mangles raw
|
||||
* sodium bytes.
|
||||
*
|
||||
* @throws SignatureException
|
||||
*/
|
||||
public static function sign(string $signatureBase, string $privateKey, string $algorithm): string {
|
||||
$normalized = self::normalize($algorithm);
|
||||
|
||||
if ($normalized === 'ed25519') {
|
||||
if (strlen($privateKey) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) {
|
||||
throw new SignatureException('Ed25519 secret key must be ' . SODIUM_CRYPTO_SIGN_SECRETKEYBYTES . ' bytes');
|
||||
}
|
||||
return sodium_crypto_sign_detached($signatureBase, $privateKey);
|
||||
}
|
||||
|
||||
try {
|
||||
return JWT::sign($signatureBase, $privateKey, self::nativeToJose($normalized));
|
||||
} catch (Throwable $e) {
|
||||
throw new SignatureException('signing failed for ' . $normalized . ': ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $signature raw signature bytes (already base64-decoded)
|
||||
* @param string|null $algorithm algorithm hint from Signature-Input `alg=`
|
||||
* @throws SignatureException
|
||||
*/
|
||||
public static function verify(string $signatureBase, string $signature, Key $key, ?string $algorithm): bool {
|
||||
$resolved = self::normalize($key->getAlgorithm());
|
||||
|
||||
if ($algorithm !== null && $algorithm !== '') {
|
||||
$hintNative = self::normalize($algorithm);
|
||||
if ($hintNative !== $resolved) {
|
||||
throw new SignatureException(
|
||||
'algorithm sources disagree: Signature-Input alg says ' . $hintNative . ', JWK alg says ' . $resolved
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$material = $key->getKeyMaterial();
|
||||
|
||||
if ($resolved === 'ed25519') {
|
||||
if (strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES) {
|
||||
return false;
|
||||
}
|
||||
// parseKey hands OKP material as plain base64 of the 32 raw bytes.
|
||||
$rawPublic = base64_decode((string)$material, true);
|
||||
if ($rawPublic === false || strlen($rawPublic) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
|
||||
return false;
|
||||
}
|
||||
return sodium_crypto_sign_verify_detached($signature, $signatureBase, $rawPublic);
|
||||
}
|
||||
|
||||
[$opensslAlgo, $encoding] = self::opensslParametersForAlgorithm($resolved);
|
||||
|
||||
if ($encoding === 'ecdsa') {
|
||||
$signature = self::ecdsaRawToDer($signature, self::ecdsaCoordinateSize($resolved));
|
||||
if ($signature === null) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return openssl_verify($signatureBase, $signature, $material, $opensslAlgo) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a JOSE alg (RFC 7518/8037) to the RFC 9421 native identifier.
|
||||
* Pass-through if already native.
|
||||
*
|
||||
* @throws SignatureException
|
||||
*/
|
||||
public static function normalize(string $algorithm): string {
|
||||
$lower = strtolower($algorithm);
|
||||
if (in_array($lower, self::NATIVE, true)) {
|
||||
return $lower;
|
||||
}
|
||||
return match ($algorithm) {
|
||||
'EdDSA' => 'ed25519',
|
||||
'ES256' => 'ecdsa-p256-sha256',
|
||||
'ES384' => 'ecdsa-p384-sha384',
|
||||
'RS256' => 'rsa-v1_5-sha256',
|
||||
'RS384' => 'rsa-v1_5-sha384',
|
||||
'RS512' => 'rsa-v1_5-sha512',
|
||||
default => throw new SignatureException('unsupported signature algorithm: ' . $algorithm),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default JOSE alg for {@see \Firebase\JWT\JWK::parseKey} when the JWK has
|
||||
* no `alg` (RFC 7517 leaves it optional). Null if kty/crv don't pin one
|
||||
* down (e.g. RSA, where the hash isn't determined).
|
||||
*
|
||||
* @param array<string, mixed> $jwk
|
||||
*/
|
||||
public static function deriveJoseAlgFromJwk(array $jwk): ?string {
|
||||
return match ($jwk['kty'] ?? '') {
|
||||
'OKP' => match ($jwk['crv'] ?? '') {
|
||||
'Ed25519' => 'EdDSA',
|
||||
default => null,
|
||||
},
|
||||
'EC' => match ($jwk['crv'] ?? '') {
|
||||
'P-256' => 'ES256',
|
||||
'P-384' => 'ES384',
|
||||
default => null,
|
||||
},
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static function nativeToJose(string $native): string {
|
||||
return match ($native) {
|
||||
'ed25519' => 'EdDSA',
|
||||
'ecdsa-p256-sha256' => 'ES256',
|
||||
'ecdsa-p384-sha384' => 'ES384',
|
||||
'rsa-v1_5-sha256' => 'RS256',
|
||||
'rsa-v1_5-sha384' => 'RS384',
|
||||
'rsa-v1_5-sha512' => 'RS512',
|
||||
default => throw new SignatureException('unsupported signature algorithm: ' . $native),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: int, 1: string} [openssl digest, wire encoding]
|
||||
*/
|
||||
private static function opensslParametersForAlgorithm(string $native): array {
|
||||
return match ($native) {
|
||||
'rsa-v1_5-sha256' => [OPENSSL_ALGO_SHA256, 'raw'],
|
||||
'rsa-v1_5-sha384' => [OPENSSL_ALGO_SHA384, 'raw'],
|
||||
'rsa-v1_5-sha512' => [OPENSSL_ALGO_SHA512, 'raw'],
|
||||
'ecdsa-p256-sha256' => [OPENSSL_ALGO_SHA256, 'ecdsa'],
|
||||
'ecdsa-p384-sha384' => [OPENSSL_ALGO_SHA384, 'ecdsa'],
|
||||
default => throw new SignatureException('unsupported signature algorithm: ' . $native),
|
||||
};
|
||||
}
|
||||
|
||||
private static function ecdsaCoordinateSize(string $native): int {
|
||||
return match ($native) {
|
||||
'ecdsa-p256-sha256' => 32,
|
||||
'ecdsa-p384-sha384' => 48,
|
||||
default => throw new InvalidArgumentException('not an ECDSA algorithm: ' . $native),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw R||S (RFC 9421 §3.3.4 wire form) to DER for openssl_verify.
|
||||
* firebase/php-jwt has the inverse but keeps it private.
|
||||
*/
|
||||
public static function ecdsaRawToDer(string $raw, int $coordinateSize): ?string {
|
||||
if (strlen($raw) !== $coordinateSize * 2) {
|
||||
return null;
|
||||
}
|
||||
$r = ltrim(substr($raw, 0, $coordinateSize), "\x00");
|
||||
$s = ltrim(substr($raw, $coordinateSize), "\x00");
|
||||
// DER INTEGER must be positive; pad if high bit is set.
|
||||
if ($r === '' || (ord($r[0]) & 0x80) !== 0) {
|
||||
$r = "\x00" . $r;
|
||||
}
|
||||
if ($s === '' || (ord($s[0]) & 0x80) !== 0) {
|
||||
$s = "\x00" . $s;
|
||||
}
|
||||
$rEncoded = "\x02" . self::derLength(strlen($r)) . $r;
|
||||
$sEncoded = "\x02" . self::derLength(strlen($s)) . $s;
|
||||
$body = $rEncoded . $sEncoded;
|
||||
return "\x30" . self::derLength(strlen($body)) . $body;
|
||||
}
|
||||
|
||||
private static function derLength(int $length): string {
|
||||
if ($length < 0x80) {
|
||||
return chr($length);
|
||||
}
|
||||
$bytes = '';
|
||||
while ($length > 0) {
|
||||
$bytes = chr($length & 0xff) . $bytes;
|
||||
$length >>= 8;
|
||||
}
|
||||
return chr(0x80 | strlen($bytes)) . $bytes;
|
||||
}
|
||||
}
|
||||
72
lib/private/Security/Signature/Rfc9421/ContentDigest.php
Normal file
72
lib/private/Security/Signature/Rfc9421/ContentDigest.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OC\Security\Signature\Rfc9421;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/** RFC 9530 `Content-Digest` helpers; covered by RFC 9421 §7.2.5 in OCM signatures. */
|
||||
final class ContentDigest {
|
||||
public const ALGO_SHA256 = 'sha-256';
|
||||
public const ALGO_SHA512 = 'sha-512';
|
||||
|
||||
public static function compute(string $body, string $algorithm = self::ALGO_SHA256): string {
|
||||
$hashAlgorithm = self::hashAlgorithmFor($algorithm);
|
||||
return $algorithm . '=:' . base64_encode(hash($hashAlgorithm, $body, true)) . ':';
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff at least one recognised algorithm matches and none mismatch.
|
||||
* Stricter than RFC 9530 §2's "any-match"; OCM treats mismatches as an
|
||||
* attack on the weaker algorithm.
|
||||
*/
|
||||
public static function verify(string $header, string $body): bool {
|
||||
$matched = false;
|
||||
foreach (self::parse($header) as $algorithm => $digest) {
|
||||
try {
|
||||
$hashAlgorithm = self::hashAlgorithmFor($algorithm);
|
||||
} catch (InvalidArgumentException) {
|
||||
continue;
|
||||
}
|
||||
if (!hash_equals(hash($hashAlgorithm, $body, true), $digest)) {
|
||||
return false;
|
||||
}
|
||||
$matched = true;
|
||||
}
|
||||
return $matched;
|
||||
}
|
||||
|
||||
/** @return array<string, string> [algorithm => raw bytes] */
|
||||
public static function parse(string $header): array {
|
||||
$out = [];
|
||||
foreach (explode(',', $header) as $entry) {
|
||||
$entry = trim($entry);
|
||||
if ($entry === '') {
|
||||
continue;
|
||||
}
|
||||
if (!preg_match('#^([a-z0-9-]+)=:([A-Za-z0-9+/=]*):$#', $entry, $m)) {
|
||||
continue;
|
||||
}
|
||||
$decoded = base64_decode($m[2], true);
|
||||
if ($decoded === false) {
|
||||
continue;
|
||||
}
|
||||
$out[strtolower($m[1])] = $decoded;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function hashAlgorithmFor(string $algorithm): string {
|
||||
return match (strtolower($algorithm)) {
|
||||
self::ALGO_SHA256 => 'sha256',
|
||||
self::ALGO_SHA512 => 'sha512',
|
||||
default => throw new InvalidArgumentException('unsupported content-digest algorithm: ' . $algorithm),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OC\Security\Signature\Rfc9421;
|
||||
|
||||
use Firebase\JWT\Key;
|
||||
use OCP\Security\Signature\ISignatoryManager;
|
||||
|
||||
/**
|
||||
* Capability bit for {@see ISignatoryManager} implementations that can resolve
|
||||
* a remote JWK for RFC 9421 verification. {@see \OC\Security\Signature\SignatureManager}
|
||||
* checks this via instanceof on the RFC 9421 path; cavage doesn't need it.
|
||||
*/
|
||||
interface IJwkResolvingSignatoryManager extends ISignatoryManager {
|
||||
/**
|
||||
* Resolve the JWK identified by $keyId for the remote at $origin and
|
||||
* return it as a parsed {@see Key}. Null when no matching JWK is found.
|
||||
*
|
||||
* @param string $origin host of the remote that signed the request
|
||||
* @param string $keyId raw `keyid` from Signature-Input; matched against JWK `kid`
|
||||
*/
|
||||
public function getRemoteKey(string $origin, string $keyId): ?Key;
|
||||
}
|
||||
124
lib/private/Security/Signature/Rfc9421/SignatureBase.php
Normal file
124
lib/private/Security/Signature/Rfc9421/SignatureBase.php
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OC\Security\Signature\Rfc9421;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use OCP\Security\Signature\Exceptions\SignatureException;
|
||||
|
||||
/**
|
||||
* RFC 9421 §2.5 signature base construction. Implements the derived
|
||||
* components OCM uses (`@method`, `@target-uri`, `@authority`, `@scheme`,
|
||||
* `@path`, `@query`, `@request-target`) plus plain HTTP fields.
|
||||
*/
|
||||
final class SignatureBase {
|
||||
/**
|
||||
* @param array<string,string> $headers headers keyed by lowercase name
|
||||
* @param list<string> $components covered component identifiers, in order
|
||||
* @param string $signatureParamsLine `(...);params...` for `@signature-params`
|
||||
* @throws SignatureException when a covered field is missing from $headers
|
||||
*/
|
||||
public static function build(
|
||||
string $method,
|
||||
string $uri,
|
||||
array $headers,
|
||||
array $components,
|
||||
string $signatureParamsLine,
|
||||
): string {
|
||||
$lines = [];
|
||||
foreach ($components as $component) {
|
||||
$lines[] = '"' . $component . '": ' . self::componentValue($component, $method, $uri, $headers);
|
||||
}
|
||||
$lines[] = '"@signature-params": ' . $signatureParamsLine;
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize `(comp...)` + `;k=v` parameters for `@signature-params` and
|
||||
* Signature-Input dictionary entries.
|
||||
*
|
||||
* @param list<string> $components
|
||||
* @param array<string, scalar> $params
|
||||
*/
|
||||
public static function serializeSignatureParams(array $components, array $params): string {
|
||||
$inner = array_map(static fn (string $c): string => '"' . $c . '"', $components);
|
||||
$out = '(' . implode(' ', $inner) . ')';
|
||||
foreach ($params as $name => $value) {
|
||||
$out .= ';' . $name . '=' . self::serializeBareItem($value);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param scalar $value
|
||||
*/
|
||||
public static function serializeBareItem(mixed $value): string {
|
||||
if (is_string($value)) {
|
||||
return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"';
|
||||
}
|
||||
if (is_int($value)) {
|
||||
return (string)$value;
|
||||
}
|
||||
if (is_bool($value)) {
|
||||
return $value ? '?1' : '?0';
|
||||
}
|
||||
throw new InvalidArgumentException('unsupported parameter value type');
|
||||
}
|
||||
|
||||
private static function componentValue(string $component, string $method, string $uri, array $headers): string {
|
||||
if (str_starts_with($component, '@')) {
|
||||
return self::derivedValue($component, $method, $uri);
|
||||
}
|
||||
$lower = strtolower($component);
|
||||
if (!array_key_exists($lower, $headers)) {
|
||||
throw new SignatureException('missing field for signature: ' . $component);
|
||||
}
|
||||
return self::normalizeFieldValue($headers[$lower]);
|
||||
}
|
||||
|
||||
private static function derivedValue(string $component, string $method, string $uri): string {
|
||||
$parts = parse_url($uri);
|
||||
if ($parts === false) {
|
||||
throw new SignatureException('cannot parse target URI');
|
||||
}
|
||||
return match ($component) {
|
||||
'@method' => strtoupper($method),
|
||||
'@target-uri' => $uri,
|
||||
'@authority' => self::authority($parts),
|
||||
'@scheme' => strtolower($parts['scheme'] ?? ''),
|
||||
'@path' => $parts['path'] ?? '/',
|
||||
'@query' => isset($parts['query']) ? '?' . $parts['query'] : '',
|
||||
'@request-target' => ($parts['path'] ?? '/') . (isset($parts['query']) ? '?' . $parts['query'] : ''),
|
||||
default => throw new SignatureException('unsupported derived component: ' . $component),
|
||||
};
|
||||
}
|
||||
|
||||
private static function authority(array $parts): string {
|
||||
$host = strtolower((string)($parts['host'] ?? ''));
|
||||
if ($host === '') {
|
||||
return '';
|
||||
}
|
||||
$port = $parts['port'] ?? null;
|
||||
$scheme = strtolower((string)($parts['scheme'] ?? ''));
|
||||
// RFC 9421 §2.2.3: default ports are omitted.
|
||||
if ($port !== null && !self::isDefaultPort($scheme, (int)$port)) {
|
||||
return $host . ':' . $port;
|
||||
}
|
||||
return $host;
|
||||
}
|
||||
|
||||
private static function isDefaultPort(string $scheme, int $port): bool {
|
||||
return ($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80);
|
||||
}
|
||||
|
||||
private static function normalizeFieldValue(string $value): string {
|
||||
// RFC 9421 §2.1: strip OWS, collapse internal whitespace.
|
||||
return preg_replace('/[ \t]+/', ' ', trim($value)) ?? '';
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,9 @@ namespace OC\Security\Signature;
|
|||
use OC\Security\Signature\Db\SignatoryMapper;
|
||||
use OC\Security\Signature\Model\IncomingSignedRequest;
|
||||
use OC\Security\Signature\Model\OutgoingSignedRequest;
|
||||
use OC\Security\Signature\Model\Rfc9421IncomingSignedRequest;
|
||||
use OC\Security\Signature\Model\Rfc9421OutgoingSignedRequest;
|
||||
use OC\Security\Signature\Rfc9421\IJwkResolvingSignatoryManager;
|
||||
use OCP\DB\Exception as DBException;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\IRequest;
|
||||
|
|
@ -101,6 +104,11 @@ class SignatureManager implements ISignatureManager {
|
|||
throw new IncomingRequestException('content of request is too big');
|
||||
}
|
||||
|
||||
// `Signature-Input` is unique to RFC 9421; cavage uses `Signature` only.
|
||||
if ($this->request->getHeader('Signature-Input') !== '') {
|
||||
return $this->getRfc9421IncomingSignedRequest($signatoryManager, $body, $options);
|
||||
}
|
||||
|
||||
// generate IncomingSignedRequest based on body and request
|
||||
$signedRequest = new IncomingSignedRequest($body, $this->request, $options);
|
||||
|
||||
|
|
@ -121,6 +129,45 @@ class SignatureManager implements ISignatureManager {
|
|||
return $signedRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 9421 inbound path. Requires {@see IJwkResolvingSignatoryManager}.
|
||||
*
|
||||
* @throws IncomingRequestException
|
||||
* @throws SignatureException
|
||||
* @throws SignatureNotFoundException
|
||||
*/
|
||||
private function getRfc9421IncomingSignedRequest(
|
||||
ISignatoryManager $signatoryManager,
|
||||
string $body,
|
||||
array $options,
|
||||
): IIncomingSignedRequest {
|
||||
if (!($signatoryManager instanceof IJwkResolvingSignatoryManager)) {
|
||||
throw new IncomingRequestException('RFC 9421 inbound is not supported by ' . get_class($signatoryManager));
|
||||
}
|
||||
|
||||
$signedRequest = new Rfc9421IncomingSignedRequest($body, $this->request, $options);
|
||||
|
||||
try {
|
||||
$key = $signatoryManager->getRemoteKey($signedRequest->getOrigin(), $signedRequest->getKeyId());
|
||||
if ($key === null) {
|
||||
throw new SignatoryNotFoundException('no JWK resolved for keyid ' . $signedRequest->getKeyId());
|
||||
}
|
||||
$signedRequest->setKey($key);
|
||||
$signedRequest->verify();
|
||||
} catch (SignatureException $e) {
|
||||
$this->logger->warning(
|
||||
'RFC 9421 signature could not be verified', [
|
||||
'exception' => $e,
|
||||
'signedRequest' => $signedRequest,
|
||||
'signatoryManager' => get_class($signatoryManager),
|
||||
]
|
||||
);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $signedRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* confirm that the Signature is signed using the correct private key, using
|
||||
* clear version of the Signature and the public key linked to the keyId
|
||||
|
|
@ -199,13 +246,22 @@ class SignatureManager implements ISignatureManager {
|
|||
string $method,
|
||||
string $uri,
|
||||
): IOutgoingSignedRequest {
|
||||
$signedRequest = new OutgoingSignedRequest(
|
||||
$content,
|
||||
$signatoryManager,
|
||||
$this->extractIdentityFromUri($uri),
|
||||
$method,
|
||||
parse_url($uri, PHP_URL_PATH) ?? '/'
|
||||
);
|
||||
$options = $signatoryManager->getOptions();
|
||||
$signedRequest = ($options['rfc9421.format'] ?? false)
|
||||
? new Rfc9421OutgoingSignedRequest(
|
||||
$content,
|
||||
$signatoryManager,
|
||||
$this->extractIdentityFromUri($uri),
|
||||
$method,
|
||||
$uri,
|
||||
)
|
||||
: new OutgoingSignedRequest(
|
||||
$content,
|
||||
$signatoryManager,
|
||||
$this->extractIdentityFromUri($uri),
|
||||
$method,
|
||||
parse_url($uri, PHP_URL_PATH) ?? '/',
|
||||
);
|
||||
|
||||
$signedRequest->sign();
|
||||
|
||||
|
|
|
|||
316
tests/lib/Security/Signature/Model/Rfc9421RoundTripTest.php
Normal file
316
tests/lib/Security/Signature/Model/Rfc9421RoundTripTest.php
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Test\Security\Signature\Model;
|
||||
|
||||
use Firebase\JWT\JWK;
|
||||
use OC\Security\Signature\Model\Rfc9421IncomingSignedRequest;
|
||||
use OC\Security\Signature\Model\Rfc9421OutgoingSignedRequest;
|
||||
use OCP\IRequest;
|
||||
use OCP\Security\Signature\Enum\DigestAlgorithm;
|
||||
use OCP\Security\Signature\Enum\SignatureAlgorithm;
|
||||
use OCP\Security\Signature\Exceptions\IncomingRequestException;
|
||||
use OCP\Security\Signature\Exceptions\InvalidSignatureException;
|
||||
use OCP\Security\Signature\Exceptions\SignatureNotFoundException;
|
||||
use OCP\Security\Signature\ISignatoryManager;
|
||||
use OCP\Security\Signature\Model\Signatory;
|
||||
use Test\TestCase;
|
||||
|
||||
class Rfc9421RoundTripTest extends TestCase {
|
||||
public function testEd25519RoundTripVerifies(): void {
|
||||
[$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519');
|
||||
$signatoryManager = $this->makeSignatoryManager($signatory);
|
||||
|
||||
$body = '{"hello":"world"}';
|
||||
$method = 'POST';
|
||||
$uri = 'https://receiver.example.org/ocm/shares';
|
||||
|
||||
$out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', $method, $uri);
|
||||
$out->sign();
|
||||
|
||||
$req = $this->mockRequestFromOutgoing($out, $method, '/ocm/shares', 'receiver.example.org');
|
||||
$in = new Rfc9421IncomingSignedRequest($body, $req);
|
||||
$in->setKey($jwk);
|
||||
|
||||
$this->assertSame($out->getSignatureBaseString(), $in->getSignatureBaseString());
|
||||
$in->verify(); // throws on failure
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function testTamperedBodyRejected(): void {
|
||||
[$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519');
|
||||
$signatoryManager = $this->makeSignatoryManager($signatory);
|
||||
|
||||
$body = 'original';
|
||||
$out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
|
||||
$out->sign();
|
||||
|
||||
$req = $this->mockRequestFromOutgoing($out, 'POST', '/ocm/shares', 'receiver.example.org');
|
||||
$this->expectException(IncomingRequestException::class);
|
||||
new Rfc9421IncomingSignedRequest('tampered', $req);
|
||||
}
|
||||
|
||||
public function testTamperedSignatureRejected(): void {
|
||||
[$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519');
|
||||
$signatoryManager = $this->makeSignatoryManager($signatory);
|
||||
|
||||
$body = 'msg';
|
||||
$out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
|
||||
$out->sign();
|
||||
|
||||
$headers = $out->getHeaders();
|
||||
// Replace the inner base64 of the signature with a different valid base64.
|
||||
$headers['Signature'] = preg_replace('/=:[^:]+:/', '=:' . base64_encode(random_bytes(64)) . ':', (string)$headers['Signature']);
|
||||
|
||||
$req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org');
|
||||
$in = new Rfc9421IncomingSignedRequest($body, $req);
|
||||
$in->setKey($jwk);
|
||||
|
||||
$this->expectException(InvalidSignatureException::class);
|
||||
$in->verify();
|
||||
}
|
||||
|
||||
public function testOutgoingUsesOcmLabel(): void {
|
||||
[$signatory] = $this->ed25519Material('https://sender.example.org/ocm#ed25519');
|
||||
$signatoryManager = $this->makeSignatoryManager($signatory);
|
||||
|
||||
$out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
|
||||
$out->sign();
|
||||
|
||||
$headers = $out->getHeaders();
|
||||
$this->assertStringStartsWith('ocm=(', (string)$headers['Signature-Input']);
|
||||
$this->assertStringStartsWith('ocm=:', (string)$headers['Signature']);
|
||||
}
|
||||
|
||||
public function testRequestWithoutOcmLabelRejected(): void {
|
||||
[$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519');
|
||||
$signatoryManager = $this->makeSignatoryManager($signatory);
|
||||
|
||||
$out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
|
||||
$out->sign();
|
||||
|
||||
// Rename the OCM label to something else; verifier MUST reject.
|
||||
$headers = $out->getHeaders();
|
||||
$headers['Signature-Input'] = preg_replace('/^ocm=/', 'sig1=', (string)$headers['Signature-Input']);
|
||||
$headers['Signature'] = preg_replace('/^ocm=/', 'sig1=', (string)$headers['Signature']);
|
||||
|
||||
$req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org');
|
||||
$this->expectException(SignatureNotFoundException::class);
|
||||
new Rfc9421IncomingSignedRequest('msg', $req);
|
||||
}
|
||||
|
||||
public function testDuplicateOcmLabelRejected(): void {
|
||||
// RFC 8941 §4.2 last-wins on duplicate dictionary keys, but OCM
|
||||
// mandates that duplicate `ocm` entries cause the request to be
|
||||
// rejected outright. The model layer enforces that.
|
||||
[$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519');
|
||||
$signatoryManager = $this->makeSignatoryManager($signatory);
|
||||
|
||||
$out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
|
||||
$out->sign();
|
||||
|
||||
$headers = $out->getHeaders();
|
||||
$headers['Signature-Input'] = (string)$headers['Signature-Input'] . ', ' . (string)$headers['Signature-Input'];
|
||||
$headers['Signature'] = (string)$headers['Signature'] . ', ' . (string)$headers['Signature'];
|
||||
|
||||
$req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org');
|
||||
$this->expectException(IncomingRequestException::class);
|
||||
new Rfc9421IncomingSignedRequest('msg', $req);
|
||||
}
|
||||
|
||||
public function testForeignSiblingLabelIgnored(): void {
|
||||
[$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519');
|
||||
$signatoryManager = $this->makeSignatoryManager($signatory);
|
||||
|
||||
$out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
|
||||
$out->sign();
|
||||
|
||||
// Splice in a sibling proxy_sig1 entry; the verifier must ignore it
|
||||
// and still verify the ocm-labeled signature successfully.
|
||||
$headers = $out->getHeaders();
|
||||
$proxyParams = '("@method");created=1;keyid="proxy"';
|
||||
$proxySig = base64_encode(random_bytes(64));
|
||||
$headers['Signature-Input'] = (string)$headers['Signature-Input'] . ', proxy_sig1=' . $proxyParams;
|
||||
$headers['Signature'] = (string)$headers['Signature'] . ', proxy_sig1=:' . $proxySig . ':';
|
||||
|
||||
$req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org');
|
||||
$in = new Rfc9421IncomingSignedRequest('msg', $req);
|
||||
$in->setKey($jwk);
|
||||
$in->verify();
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function testTooOldSignatureRejected(): void {
|
||||
[$signatory] = $this->ed25519Material('https://sender.example.org/ocm#ed25519');
|
||||
$signatoryManager = $this->makeSignatoryManager($signatory);
|
||||
|
||||
$body = 'msg';
|
||||
$out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
|
||||
$out->sign();
|
||||
|
||||
// Backdate `created` in Signature-Input by 10 minutes.
|
||||
$headers = $out->getHeaders();
|
||||
$pastCreated = time() - 600;
|
||||
$headers['Signature-Input'] = preg_replace('/created=\d+/', 'created=' . $pastCreated, (string)$headers['Signature-Input']);
|
||||
|
||||
$req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org');
|
||||
$this->expectException(IncomingRequestException::class);
|
||||
new Rfc9421IncomingSignedRequest($body, $req, ['ttl' => 300]);
|
||||
}
|
||||
|
||||
public function testFutureCreatedRejected(): void {
|
||||
[$signatory] = $this->ed25519Material('https://sender.example.org/ocm#ed25519');
|
||||
$signatoryManager = $this->makeSignatoryManager($signatory);
|
||||
|
||||
$body = 'msg';
|
||||
$out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
|
||||
$out->sign();
|
||||
|
||||
// Push `created` 10 minutes into the future, well past the
|
||||
// 60-second skew tolerance.
|
||||
$headers = $out->getHeaders();
|
||||
$futureCreated = time() + 600;
|
||||
$headers['Signature-Input'] = preg_replace('/created=\d+/', 'created=' . $futureCreated, (string)$headers['Signature-Input']);
|
||||
|
||||
$req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org');
|
||||
$this->expectException(IncomingRequestException::class);
|
||||
new Rfc9421IncomingSignedRequest($body, $req);
|
||||
}
|
||||
|
||||
public function testMissingCreatedRejected(): void {
|
||||
[$signatory] = $this->ed25519Material('https://sender.example.org/ocm#ed25519');
|
||||
$signatoryManager = $this->makeSignatoryManager($signatory);
|
||||
|
||||
$body = 'msg';
|
||||
$out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
|
||||
$out->sign();
|
||||
|
||||
// Strip the `;created=...` parameter so the signature loses its
|
||||
// freshness anchor.
|
||||
$headers = $out->getHeaders();
|
||||
$headers['Signature-Input'] = preg_replace('/;created=\d+/', '', (string)$headers['Signature-Input']);
|
||||
|
||||
$req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org');
|
||||
$this->expectException(IncomingRequestException::class);
|
||||
new Rfc9421IncomingSignedRequest($body, $req);
|
||||
}
|
||||
|
||||
public function testSignatureNotCoveringRequiredComponentsRejected(): void {
|
||||
// A peer that signs only `@method` and `@target-uri`: the body and
|
||||
// freshness window aren't bound. Even with a valid signature we
|
||||
// must refuse it.
|
||||
[$signatory] = $this->ed25519Material('https://sender.example.org/ocm#ed25519');
|
||||
$signatoryManager = $this->makeSignatoryManagerWithComponents(
|
||||
$signatory,
|
||||
['@method', '@target-uri'],
|
||||
);
|
||||
|
||||
$body = 'msg';
|
||||
$out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
|
||||
$out->sign();
|
||||
$req = $this->mockRequest($out->getHeaders(), 'POST', '/ocm/shares', 'receiver.example.org');
|
||||
|
||||
$this->expectException(IncomingRequestException::class);
|
||||
new Rfc9421IncomingSignedRequest($body, $req);
|
||||
}
|
||||
|
||||
private function makeSignatoryManagerWithComponents(Signatory $signatory, array $components): ISignatoryManager {
|
||||
return new class($signatory, $components) implements ISignatoryManager {
|
||||
public function __construct(
|
||||
private Signatory $sig,
|
||||
private array $components,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getProviderId(): string {
|
||||
return 'test';
|
||||
}
|
||||
|
||||
public function getOptions(): array {
|
||||
return [
|
||||
'algorithm' => SignatureAlgorithm::RSA_SHA256,
|
||||
'digestAlgorithm' => DigestAlgorithm::SHA256,
|
||||
'rfc9421.coveredComponents' => $this->components,
|
||||
];
|
||||
}
|
||||
|
||||
public function getLocalSignatory(): Signatory {
|
||||
return $this->sig;
|
||||
}
|
||||
|
||||
public function getRemoteSignatory(string $remote): ?Signatory {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function ed25519Material(string $kid): array {
|
||||
$keypair = sodium_crypto_sign_keypair();
|
||||
$publicKey = sodium_crypto_sign_publickey($keypair);
|
||||
$secretKey = sodium_crypto_sign_secretkey($keypair);
|
||||
$signatory = new Signatory(true);
|
||||
$signatory->setKeyId($kid);
|
||||
$signatory->setPublicKey($publicKey);
|
||||
$signatory->setPrivateKey($secretKey);
|
||||
$key = JWK::parseKey([
|
||||
'kty' => 'OKP',
|
||||
'crv' => 'Ed25519',
|
||||
'kid' => $kid,
|
||||
'alg' => 'EdDSA',
|
||||
'x' => rtrim(strtr(base64_encode($publicKey), '+/', '-_'), '='),
|
||||
], 'EdDSA');
|
||||
return [$signatory, $key];
|
||||
}
|
||||
|
||||
private function makeSignatoryManager(Signatory $signatory): ISignatoryManager {
|
||||
return new class($signatory) implements ISignatoryManager {
|
||||
public function __construct(
|
||||
private Signatory $sig,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getProviderId(): string {
|
||||
return 'test';
|
||||
}
|
||||
|
||||
public function getOptions(): array {
|
||||
return [
|
||||
'algorithm' => SignatureAlgorithm::RSA_SHA256,
|
||||
'digestAlgorithm' => DigestAlgorithm::SHA256,
|
||||
];
|
||||
}
|
||||
|
||||
public function getLocalSignatory(): Signatory {
|
||||
return $this->sig;
|
||||
}
|
||||
|
||||
public function getRemoteSignatory(string $remote): ?Signatory {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function mockRequestFromOutgoing(Rfc9421OutgoingSignedRequest $out, string $method, string $path, string $host): IRequest {
|
||||
return $this->mockRequest($out->getHeaders(), $method, $path, $host);
|
||||
}
|
||||
|
||||
private function mockRequest(array $headers, string $method, string $path, string $host): IRequest {
|
||||
$lowered = [];
|
||||
foreach ($headers as $name => $value) {
|
||||
$lowered[strtolower($name)] = (string)$value;
|
||||
}
|
||||
$mock = $this->createMock(IRequest::class);
|
||||
$mock->method('getHeader')->willReturnCallback(static fn (string $h) => $lowered[strtolower($h)] ?? '');
|
||||
$mock->method('getMethod')->willReturn($method);
|
||||
$mock->method('getRequestUri')->willReturn($path);
|
||||
$mock->method('getServerProtocol')->willReturn('https');
|
||||
$mock->method('getServerHost')->willReturn($host);
|
||||
return $mock;
|
||||
}
|
||||
}
|
||||
197
tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php
Normal file
197
tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Test\Security\Signature\Rfc9421;
|
||||
|
||||
use Firebase\JWT\JWK;
|
||||
use Firebase\JWT\Key;
|
||||
use OC\Security\Signature\Rfc9421\Algorithm;
|
||||
use OCP\Security\Signature\Exceptions\SignatureException;
|
||||
use Test\TestCase;
|
||||
|
||||
class AlgorithmTest extends TestCase {
|
||||
public function testNormalizeNativeIsPassThrough(): void {
|
||||
$this->assertSame('ed25519', Algorithm::normalize('ed25519'));
|
||||
$this->assertSame('rsa-v1_5-sha256', Algorithm::normalize('rsa-v1_5-sha256'));
|
||||
$this->assertSame('ecdsa-p256-sha256', Algorithm::normalize('ecdsa-p256-sha256'));
|
||||
}
|
||||
|
||||
public function testNormalizeJoseAliases(): void {
|
||||
$this->assertSame('ed25519', Algorithm::normalize('EdDSA'));
|
||||
$this->assertSame('ecdsa-p256-sha256', Algorithm::normalize('ES256'));
|
||||
$this->assertSame('ecdsa-p384-sha384', Algorithm::normalize('ES384'));
|
||||
$this->assertSame('rsa-v1_5-sha256', Algorithm::normalize('RS256'));
|
||||
}
|
||||
|
||||
public function testNormalizeRejectsUnknown(): void {
|
||||
$this->expectException(SignatureException::class);
|
||||
Algorithm::normalize('totally-not-real');
|
||||
}
|
||||
|
||||
public function testNormalizeRejectsRsaPss(): void {
|
||||
$this->expectException(SignatureException::class);
|
||||
Algorithm::normalize('rsa-pss-sha512');
|
||||
}
|
||||
|
||||
public function testNormalizeRejectsJosePsAlias(): void {
|
||||
$this->expectException(SignatureException::class);
|
||||
Algorithm::normalize('PS512');
|
||||
}
|
||||
|
||||
public function testDeriveJoseAlgFromJwk(): void {
|
||||
$this->assertSame('EdDSA', Algorithm::deriveJoseAlgFromJwk(['kty' => 'OKP', 'crv' => 'Ed25519']));
|
||||
$this->assertSame('ES256', Algorithm::deriveJoseAlgFromJwk(['kty' => 'EC', 'crv' => 'P-256']));
|
||||
$this->assertSame('ES384', Algorithm::deriveJoseAlgFromJwk(['kty' => 'EC', 'crv' => 'P-384']));
|
||||
// RSA: hash function isn't determined by key shape.
|
||||
$this->assertNull(Algorithm::deriveJoseAlgFromJwk(['kty' => 'RSA']));
|
||||
$this->assertNull(Algorithm::deriveJoseAlgFromJwk([]));
|
||||
}
|
||||
|
||||
public function testEd25519RoundTrip(): void {
|
||||
[$priv, $key] = $this->ed25519KeyPair();
|
||||
$base = 'arbitrary signature base';
|
||||
$sig = Algorithm::sign($base, $priv, 'ed25519');
|
||||
$this->assertSame(64, strlen($sig));
|
||||
$this->assertTrue(Algorithm::verify($base, $sig, $key, 'ed25519'));
|
||||
// JOSE alias accepted.
|
||||
$this->assertTrue(Algorithm::verify($base, $sig, $key, 'EdDSA'));
|
||||
// alg-omitted path resolves through Key alg.
|
||||
$this->assertTrue(Algorithm::verify($base, $sig, $key, null));
|
||||
// tamper detection
|
||||
$this->assertFalse(Algorithm::verify($base . 'x', $sig, $key, 'ed25519'));
|
||||
}
|
||||
|
||||
public function testRsaPkcs1RoundTrip(): void {
|
||||
[$priv, $key] = $this->rsaKeyPair();
|
||||
$sig = Algorithm::sign('payload', $priv, 'rsa-v1_5-sha256');
|
||||
$this->assertSame(256, strlen($sig));
|
||||
$this->assertTrue(Algorithm::verify('payload', $sig, $key, 'rsa-v1_5-sha256'));
|
||||
$this->assertTrue(Algorithm::verify('payload', $sig, $key, 'RS256'));
|
||||
}
|
||||
|
||||
public function testEcdsaP256RoundTrip(): void {
|
||||
[$priv, $key] = $this->ecKeyPair('prime256v1', 'P-256', 'ES256');
|
||||
$sig = Algorithm::sign('payload', $priv, 'ecdsa-p256-sha256');
|
||||
$this->assertSame(64, strlen($sig));
|
||||
$this->assertTrue(Algorithm::verify('payload', $sig, $key, 'ecdsa-p256-sha256'));
|
||||
$this->assertTrue(Algorithm::verify('payload', $sig, $key, 'ES256'));
|
||||
}
|
||||
|
||||
public function testEcdsaP384RoundTrip(): void {
|
||||
[$priv, $key] = $this->ecKeyPair('secp384r1', 'P-384', 'ES384');
|
||||
$sig = Algorithm::sign('payload', $priv, 'ecdsa-p384-sha384');
|
||||
$this->assertSame(96, strlen($sig));
|
||||
$this->assertTrue(Algorithm::verify('payload', $sig, $key, 'ecdsa-p384-sha384'));
|
||||
}
|
||||
|
||||
public function testKeyTypeMismatchFailsClosed(): void {
|
||||
[, $rsaKey] = $this->rsaKeyPair();
|
||||
$this->expectException(SignatureException::class);
|
||||
Algorithm::verify('payload', random_bytes(64), $rsaKey, 'ed25519');
|
||||
}
|
||||
|
||||
public function testAlgHintConflictsWithJwkAlgRejected(): void {
|
||||
// Ed25519 JWK, request claims ES256: RFC 9421 §3.2 step 6 disagreement.
|
||||
[, $key] = $this->ed25519KeyPair();
|
||||
$this->expectException(SignatureException::class);
|
||||
Algorithm::verify('payload', random_bytes(64), $key, 'ES256');
|
||||
}
|
||||
|
||||
public function testParseKeyRejectsContradictoryAlg(): void {
|
||||
// kty=OKP/crv=Ed25519 with alg=ES256 is contradictory; firebase's
|
||||
// parseKey rejects it before we ever build a Key.
|
||||
$keypair = sodium_crypto_sign_keypair();
|
||||
$this->expectException(\Throwable::class);
|
||||
JWK::parseKey([
|
||||
'kty' => 'OKP',
|
||||
'crv' => 'Ed25519',
|
||||
'kid' => 'k',
|
||||
'alg' => 'ES256',
|
||||
'x' => self::b64url(sodium_crypto_sign_publickey($keypair)),
|
||||
], null);
|
||||
}
|
||||
|
||||
public function testAlgHintAgreesViaJoseAlias(): void {
|
||||
[$priv, $key] = $this->ed25519KeyPair();
|
||||
$base = 'agreement check';
|
||||
$sig = Algorithm::sign($base, $priv, 'ed25519');
|
||||
$this->assertTrue(Algorithm::verify($base, $sig, $key, 'ed25519'));
|
||||
$this->assertTrue(Algorithm::verify($base, $sig, $key, 'EdDSA'));
|
||||
}
|
||||
|
||||
public function testEcdsaRawToDerProducesValidSignature(): void {
|
||||
[$priv, $key] = $this->ecKeyPair('prime256v1', 'P-256', 'ES256');
|
||||
$rawSig = Algorithm::sign('msg', $priv, 'ecdsa-p256-sha256');
|
||||
$der = Algorithm::ecdsaRawToDer($rawSig, 32);
|
||||
$this->assertNotNull($der);
|
||||
$this->assertTrue(Algorithm::verify('msg', $rawSig, $key, 'ecdsa-p256-sha256'));
|
||||
}
|
||||
|
||||
public function testEcdsaRawToDerWrongLength(): void {
|
||||
$this->assertNull(Algorithm::ecdsaRawToDer('short', 32));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: Key}
|
||||
*/
|
||||
private function ed25519KeyPair(): array {
|
||||
$keypair = sodium_crypto_sign_keypair();
|
||||
$publicKey = sodium_crypto_sign_publickey($keypair);
|
||||
$secretKey = sodium_crypto_sign_secretkey($keypair);
|
||||
$key = JWK::parseKey([
|
||||
'kty' => 'OKP',
|
||||
'crv' => 'Ed25519',
|
||||
'kid' => 'k',
|
||||
'alg' => 'EdDSA',
|
||||
'x' => self::b64url($publicKey),
|
||||
], 'EdDSA');
|
||||
return [$secretKey, $key];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: Key}
|
||||
*/
|
||||
private function rsaKeyPair(): array {
|
||||
$pkey = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_RSA, 'private_key_bits' => 2048]);
|
||||
$priv = '';
|
||||
openssl_pkey_export($pkey, $priv);
|
||||
$details = openssl_pkey_get_details($pkey);
|
||||
$key = JWK::parseKey([
|
||||
'kty' => 'RSA',
|
||||
'kid' => 'k',
|
||||
'alg' => 'RS256',
|
||||
'n' => self::b64url($details['rsa']['n']),
|
||||
'e' => self::b64url($details['rsa']['e']),
|
||||
], 'RS256');
|
||||
return [$priv, $key];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: Key}
|
||||
*/
|
||||
private function ecKeyPair(string $opensslCurve, string $jwkCurve, string $joseAlg): array {
|
||||
$pkey = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => $opensslCurve]);
|
||||
$priv = '';
|
||||
openssl_pkey_export($pkey, $priv);
|
||||
$details = openssl_pkey_get_details($pkey);
|
||||
$key = JWK::parseKey([
|
||||
'kty' => 'EC',
|
||||
'crv' => $jwkCurve,
|
||||
'kid' => 'k',
|
||||
'alg' => $joseAlg,
|
||||
'x' => self::b64url($details['ec']['x']),
|
||||
'y' => self::b64url($details['ec']['y']),
|
||||
], $joseAlg);
|
||||
return [$priv, $key];
|
||||
}
|
||||
|
||||
private static function b64url(string $bin): string {
|
||||
return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
76
tests/lib/Security/Signature/Rfc9421/ContentDigestTest.php
Normal file
76
tests/lib/Security/Signature/Rfc9421/ContentDigestTest.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Test\Security\Signature\Rfc9421;
|
||||
|
||||
use OC\Security\Signature\Rfc9421\ContentDigest;
|
||||
use Test\TestCase;
|
||||
|
||||
class ContentDigestTest extends TestCase {
|
||||
public function testComputeRoundTrip(): void {
|
||||
$body = '{"hello":"world"}';
|
||||
$header = ContentDigest::compute($body, ContentDigest::ALGO_SHA256);
|
||||
$this->assertStringStartsWith('sha-256=:', $header);
|
||||
$this->assertStringEndsWith(':', $header);
|
||||
$this->assertTrue(ContentDigest::verify($header, $body));
|
||||
}
|
||||
|
||||
public function testDifferentBodyFails(): void {
|
||||
$header = ContentDigest::compute('hello', ContentDigest::ALGO_SHA256);
|
||||
$this->assertFalse(ContentDigest::verify($header, 'goodbye'));
|
||||
}
|
||||
|
||||
public function testSha512(): void {
|
||||
$header = ContentDigest::compute('payload', ContentDigest::ALGO_SHA512);
|
||||
$this->assertStringStartsWith('sha-512=:', $header);
|
||||
$this->assertTrue(ContentDigest::verify($header, 'payload'));
|
||||
}
|
||||
|
||||
public function testParseMultipleAlgorithmsAcceptsAnyMatch(): void {
|
||||
$body = 'data';
|
||||
$sha256 = ContentDigest::compute($body, ContentDigest::ALGO_SHA256);
|
||||
$sha512 = ContentDigest::compute($body, ContentDigest::ALGO_SHA512);
|
||||
$header = $sha256 . ', ' . $sha512;
|
||||
$this->assertTrue(ContentDigest::verify($header, $body));
|
||||
}
|
||||
|
||||
public function testFailsIfAnyRecognisedAlgorithmMismatches(): void {
|
||||
// All recognised digests must agree. A correct sha-256 alongside a
|
||||
// wrong sha-512 is treated as an attack on the weaker algorithm,
|
||||
// not as a successful match on the stronger one.
|
||||
$body = 'data';
|
||||
$sha256 = ContentDigest::compute($body, ContentDigest::ALGO_SHA256);
|
||||
$wrongSha512 = 'sha-512=:' . base64_encode(hash('sha512', 'tampered', true)) . ':';
|
||||
$this->assertFalse(ContentDigest::verify($sha256 . ', ' . $wrongSha512, $body));
|
||||
// And the inverse ordering.
|
||||
$this->assertFalse(ContentDigest::verify($wrongSha512 . ', ' . $sha256, $body));
|
||||
}
|
||||
|
||||
public function testUnknownAlgorithmIsIgnored(): void {
|
||||
$body = 'data';
|
||||
$sha256 = ContentDigest::compute($body, ContentDigest::ALGO_SHA256);
|
||||
$header = 'md5=:abcd:, ' . $sha256;
|
||||
$this->assertTrue(ContentDigest::verify($header, $body));
|
||||
}
|
||||
|
||||
public function testEmptyHeaderFails(): void {
|
||||
$this->assertFalse(ContentDigest::verify('', 'body'));
|
||||
}
|
||||
|
||||
public function testGarbageHeaderFails(): void {
|
||||
$this->assertFalse(ContentDigest::verify('not a digest', 'body'));
|
||||
}
|
||||
|
||||
public function testParseExtractsRawBytes(): void {
|
||||
$header = ContentDigest::compute('abc', ContentDigest::ALGO_SHA256);
|
||||
$parsed = ContentDigest::parse($header);
|
||||
$this->assertArrayHasKey('sha-256', $parsed);
|
||||
$this->assertSame(hash('sha256', 'abc', true), $parsed['sha-256']);
|
||||
}
|
||||
}
|
||||
85
tests/lib/Security/Signature/Rfc9421/SignatureBaseTest.php
Normal file
85
tests/lib/Security/Signature/Rfc9421/SignatureBaseTest.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Test\Security\Signature\Rfc9421;
|
||||
|
||||
use OC\Security\Signature\Rfc9421\SignatureBase;
|
||||
use OCP\Security\Signature\Exceptions\SignatureException;
|
||||
use Test\TestCase;
|
||||
|
||||
class SignatureBaseTest extends TestCase {
|
||||
public function testBuildBasicComponents(): void {
|
||||
$base = SignatureBase::build(
|
||||
method: 'POST',
|
||||
uri: 'https://example.org/foo?bar=baz',
|
||||
headers: [
|
||||
'content-digest' => 'sha-256=:abcd:',
|
||||
'date' => 'Mon, 04 May 2026 12:00:00 GMT',
|
||||
],
|
||||
components: ['@method', '@target-uri', 'content-digest', 'date'],
|
||||
signatureParamsLine: '("@method" "@target-uri" "content-digest" "date");created=1;keyid="k"',
|
||||
);
|
||||
|
||||
$expected = '"@method": POST' . "\n"
|
||||
. '"@target-uri": https://example.org/foo?bar=baz' . "\n"
|
||||
. '"content-digest": sha-256=:abcd:' . "\n"
|
||||
. '"date": Mon, 04 May 2026 12:00:00 GMT' . "\n"
|
||||
. '"@signature-params": ("@method" "@target-uri" "content-digest" "date");created=1;keyid="k"';
|
||||
$this->assertSame($expected, $base);
|
||||
}
|
||||
|
||||
public function testAuthorityStripsDefaultPort(): void {
|
||||
$base = SignatureBase::build('GET', 'https://EXAMPLE.org:443/x', [], ['@authority'], '()');
|
||||
$this->assertStringContainsString('"@authority": example.org' . "\n", $base);
|
||||
}
|
||||
|
||||
public function testAuthorityKeepsCustomPort(): void {
|
||||
$base = SignatureBase::build('GET', 'https://example.org:8443/x', [], ['@authority'], '()');
|
||||
$this->assertStringContainsString('"@authority": example.org:8443' . "\n", $base);
|
||||
}
|
||||
|
||||
public function testQueryComponent(): void {
|
||||
$base = SignatureBase::build('GET', 'https://example.org/x?a=1', [], ['@query'], '()');
|
||||
$this->assertStringContainsString('"@query": ?a=1' . "\n", $base);
|
||||
}
|
||||
|
||||
public function testMissingFieldThrows(): void {
|
||||
$this->expectException(SignatureException::class);
|
||||
SignatureBase::build('GET', 'https://example.org/', [], ['x-missing'], '()');
|
||||
}
|
||||
|
||||
public function testFieldValueIsTrimmed(): void {
|
||||
$base = SignatureBase::build(
|
||||
'GET',
|
||||
'https://example.org/',
|
||||
['date' => ' Mon, 04 May 2026 12:00:00 GMT '],
|
||||
['date'],
|
||||
'()'
|
||||
);
|
||||
$this->assertStringContainsString('"date": Mon, 04 May 2026 12:00:00 GMT' . "\n", $base);
|
||||
}
|
||||
|
||||
public function testSerializeSignatureParams(): void {
|
||||
$line = SignatureBase::serializeSignatureParams(
|
||||
['@method', '@target-uri'],
|
||||
['created' => 100, 'keyid' => 'kid', 'expires' => 200],
|
||||
);
|
||||
$this->assertSame('("@method" "@target-uri");created=100;keyid="kid";expires=200', $line);
|
||||
}
|
||||
|
||||
public function testSerializeBareItemEscapesQuotes(): void {
|
||||
$this->assertSame('"\\"hi\\""', SignatureBase::serializeBareItem('"hi"'));
|
||||
$this->assertSame('"\\\\"', SignatureBase::serializeBareItem('\\'));
|
||||
}
|
||||
|
||||
public function testSerializeBareItemBoolean(): void {
|
||||
$this->assertSame('?1', SignatureBase::serializeBareItem(true));
|
||||
$this->assertSame('?0', SignatureBase::serializeBareItem(false));
|
||||
}
|
||||
}
|
||||
262
tests/lib/Security/Signature/SignatureManagerDispatchTest.php
Normal file
262
tests/lib/Security/Signature/SignatureManagerDispatchTest.php
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Test\Security\Signature;
|
||||
|
||||
use Firebase\JWT\JWK;
|
||||
use Firebase\JWT\Key;
|
||||
use OC\Security\Signature\Db\SignatoryMapper;
|
||||
use OC\Security\Signature\Model\Rfc9421IncomingSignedRequest;
|
||||
use OC\Security\Signature\Model\Rfc9421OutgoingSignedRequest;
|
||||
use OC\Security\Signature\Rfc9421\IJwkResolvingSignatoryManager;
|
||||
use OC\Security\Signature\SignatureManager;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\IRequest;
|
||||
use OCP\Security\Signature\Enum\DigestAlgorithm;
|
||||
use OCP\Security\Signature\Enum\SignatureAlgorithm;
|
||||
use OCP\Security\Signature\Exceptions\IncomingRequestException;
|
||||
use OCP\Security\Signature\ISignatoryManager;
|
||||
use OCP\Security\Signature\Model\Signatory;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Test\TestCase;
|
||||
|
||||
class SignatureManagerDispatchTest extends TestCase {
|
||||
private IRequest&MockObject $request;
|
||||
private SignatoryMapper&MockObject $mapper;
|
||||
private IAppConfig&MockObject $appConfig;
|
||||
private LoggerInterface&MockObject $logger;
|
||||
private SignatureManager $signatureManager;
|
||||
|
||||
#[\Override]
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->request = $this->createMock(IRequest::class);
|
||||
$this->mapper = $this->createMock(SignatoryMapper::class);
|
||||
$this->appConfig = $this->createMock(IAppConfig::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$this->signatureManager = new SignatureManager(
|
||||
$this->request,
|
||||
$this->mapper,
|
||||
$this->appConfig,
|
||||
$this->logger,
|
||||
);
|
||||
}
|
||||
|
||||
public function testOutgoingDispatchesToCavageByDefault(): void {
|
||||
// Cavage signs with an RSA PEM, so we need a real RSA keypair here;
|
||||
// the Ed25519 helper would produce libsodium bytes that openssl_sign
|
||||
// can't consume.
|
||||
$signatoryManager = $this->rsaSignatoryManager();
|
||||
|
||||
$signed = $this->signatureManager->getOutgoingSignedRequest(
|
||||
$signatoryManager,
|
||||
'{}',
|
||||
'POST',
|
||||
'https://receiver.example.org/ocm/shares',
|
||||
);
|
||||
|
||||
$this->assertNotInstanceOf(Rfc9421OutgoingSignedRequest::class, $signed);
|
||||
}
|
||||
|
||||
public function testOutgoingDispatchesToRfc9421WhenOptionSet(): void {
|
||||
[$signatoryManager,] = $this->ed25519SignatoryManager(rfc9421Format: true);
|
||||
|
||||
$signed = $this->signatureManager->getOutgoingSignedRequest(
|
||||
$signatoryManager,
|
||||
'{}',
|
||||
'POST',
|
||||
'https://receiver.example.org/ocm/shares',
|
||||
);
|
||||
|
||||
$this->assertInstanceOf(Rfc9421OutgoingSignedRequest::class, $signed);
|
||||
$headers = $signed->getHeaders();
|
||||
$this->assertArrayHasKey('Signature-Input', $headers);
|
||||
$this->assertStringStartsWith('ocm=(', (string)$headers['Signature-Input']);
|
||||
}
|
||||
|
||||
public function testInboundDispatchesToRfc9421WhenSignatureInputPresent(): void {
|
||||
[$signatoryManager, $jwk, $secret] = $this->ed25519SignatoryManager(rfc9421Format: true);
|
||||
|
||||
// Build a real signed request and replay its headers as the inbound
|
||||
// request to exercise the full inbound path including verification.
|
||||
$body = '{"hello":"world"}';
|
||||
$out = new Rfc9421OutgoingSignedRequest(
|
||||
$body,
|
||||
$signatoryManager,
|
||||
'receiver.example.org',
|
||||
'POST',
|
||||
'https://receiver.example.org/ocm/shares',
|
||||
);
|
||||
$out->sign();
|
||||
$headers = $out->getHeaders();
|
||||
|
||||
$this->primeRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org');
|
||||
|
||||
$resolver = $this->makeKeyResolver($signatoryManager, $jwk, 'https://sender.example.org/ocm#ed25519');
|
||||
|
||||
$signed = $this->signatureManager->getIncomingSignedRequest($resolver, $body);
|
||||
$this->assertInstanceOf(Rfc9421IncomingSignedRequest::class, $signed);
|
||||
}
|
||||
|
||||
public function testInboundRejectsRfc9421WhenSignatoryManagerCannotResolve(): void {
|
||||
[$signatoryManager,] = $this->ed25519SignatoryManager(rfc9421Format: true);
|
||||
|
||||
$body = '{"hello":"world"}';
|
||||
$out = new Rfc9421OutgoingSignedRequest(
|
||||
$body,
|
||||
$signatoryManager,
|
||||
'receiver.example.org',
|
||||
'POST',
|
||||
'https://receiver.example.org/ocm/shares',
|
||||
);
|
||||
$out->sign();
|
||||
$this->primeRequest($out->getHeaders(), 'POST', '/ocm/shares', 'receiver.example.org');
|
||||
|
||||
// $signatoryManager does NOT implement IJwkResolvingSignatoryManager.
|
||||
$this->expectException(IncomingRequestException::class);
|
||||
$this->signatureManager->getIncomingSignedRequest($signatoryManager, $body);
|
||||
}
|
||||
|
||||
private function rsaSignatoryManager(): ISignatoryManager {
|
||||
$key = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_RSA, 'private_key_bits' => 2048]);
|
||||
$priv = '';
|
||||
openssl_pkey_export($key, $priv);
|
||||
$pub = openssl_pkey_get_details($key)['key'];
|
||||
|
||||
$signatory = new Signatory(true);
|
||||
$signatory->setKeyId('https://sender.example.org/ocm#signature');
|
||||
$signatory->setPublicKey($pub);
|
||||
$signatory->setPrivateKey($priv);
|
||||
|
||||
return new class($signatory) implements ISignatoryManager {
|
||||
public function __construct(
|
||||
private Signatory $signatory,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getProviderId(): string {
|
||||
return 'test';
|
||||
}
|
||||
|
||||
public function getOptions(): array {
|
||||
return [
|
||||
'algorithm' => SignatureAlgorithm::RSA_SHA256,
|
||||
'digestAlgorithm' => DigestAlgorithm::SHA256,
|
||||
];
|
||||
}
|
||||
|
||||
public function getLocalSignatory(): Signatory {
|
||||
return $this->signatory;
|
||||
}
|
||||
|
||||
public function getRemoteSignatory(string $remote): ?Signatory {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ISignatoryManager, Key, string} [manager, parsed verification key, raw secret key]
|
||||
*/
|
||||
private function ed25519SignatoryManager(bool $rfc9421Format): array {
|
||||
$keypair = sodium_crypto_sign_keypair();
|
||||
$publicKey = sodium_crypto_sign_publickey($keypair);
|
||||
$secretKey = sodium_crypto_sign_secretkey($keypair);
|
||||
$kid = 'https://sender.example.org/ocm#ed25519';
|
||||
|
||||
$signatory = new Signatory(true);
|
||||
$signatory->setKeyId($kid);
|
||||
$signatory->setPublicKey($publicKey);
|
||||
$signatory->setPrivateKey($secretKey);
|
||||
|
||||
$key = JWK::parseKey([
|
||||
'kty' => 'OKP',
|
||||
'crv' => 'Ed25519',
|
||||
'kid' => $kid,
|
||||
'alg' => 'EdDSA',
|
||||
'x' => rtrim(strtr(base64_encode($publicKey), '+/', '-_'), '='),
|
||||
], 'EdDSA');
|
||||
|
||||
$manager = new class($signatory, $rfc9421Format) implements ISignatoryManager {
|
||||
public function __construct(
|
||||
private Signatory $signatory,
|
||||
private bool $rfc9421,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getProviderId(): string {
|
||||
return 'test';
|
||||
}
|
||||
|
||||
public function getOptions(): array {
|
||||
return [
|
||||
'algorithm' => SignatureAlgorithm::RSA_SHA256,
|
||||
'digestAlgorithm' => DigestAlgorithm::SHA256,
|
||||
'rfc9421.format' => $this->rfc9421,
|
||||
];
|
||||
}
|
||||
|
||||
public function getLocalSignatory(): Signatory {
|
||||
return $this->signatory;
|
||||
}
|
||||
|
||||
public function getRemoteSignatory(string $remote): ?Signatory {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return [$manager, $key, $secretKey];
|
||||
}
|
||||
|
||||
private function makeKeyResolver(ISignatoryManager $delegate, Key $key, string $kid): IJwkResolvingSignatoryManager {
|
||||
return new class($delegate, $key, $kid) implements IJwkResolvingSignatoryManager {
|
||||
public function __construct(
|
||||
private ISignatoryManager $delegate,
|
||||
private Key $key,
|
||||
private string $kid,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getProviderId(): string {
|
||||
return $this->delegate->getProviderId();
|
||||
}
|
||||
|
||||
public function getOptions(): array {
|
||||
return $this->delegate->getOptions();
|
||||
}
|
||||
|
||||
public function getLocalSignatory(): Signatory {
|
||||
return $this->delegate->getLocalSignatory();
|
||||
}
|
||||
|
||||
public function getRemoteSignatory(string $remote): ?Signatory {
|
||||
return $this->delegate->getRemoteSignatory($remote);
|
||||
}
|
||||
|
||||
public function getRemoteKey(string $origin, string $keyId): ?Key {
|
||||
return $keyId === $this->kid ? $this->key : null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function primeRequest(array $headers, string $method, string $path, string $host): void {
|
||||
$lowered = [];
|
||||
foreach ($headers as $name => $value) {
|
||||
$lowered[strtolower($name)] = (string)$value;
|
||||
}
|
||||
$this->request->method('getHeader')
|
||||
->willReturnCallback(static fn (string $name) => $lowered[strtolower($name)] ?? '');
|
||||
$this->request->method('getMethod')->willReturn($method);
|
||||
$this->request->method('getRequestUri')->willReturn($path);
|
||||
$this->request->method('getServerProtocol')->willReturn('https');
|
||||
$this->request->method('getServerHost')->willReturn($host);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue