Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Signed requests #45979

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open

Signed requests #45979

wants to merge 2 commits into from

Conversation

ArtificialOwl
Copy link
Member

@ArtificialOwl ArtificialOwl commented Jun 19, 2024

Signed Request

Signing request allows to confirm the identity of the sender.
Signing request does not encrypt nor affect its payload.
Signing request only adds metadata to the headers of the request.

Signature

The concept is to add unique metadata and sign them using a private/public key pair.
The location of the public key used to verify the signature will confirm the origin of the request.

Signature does not affect the data of the request, it only adds headers to it:

 {
     "(request-target)": "post /path",
     "content-length": 380,
     "date": "Mon, 08 Jul 2024 14:16:20 GMT",
     "digest": "SHA-256=U7gNVUQiixe5BRbp4Tg0xCZMTcSWXXUZI2\\/xtHM40S0=",
     "host": "hostname.of.the.recipient",
     "Signature": "keyId=\"https://author.hostname/key\",algorithm=\"ras-sha256\",headers=\"content-length date digest host\",signature=\"DzN12OCS1rsA[...]o0VmxjQooRo6HHabg==\""
 }
  • 'content-length' is the total length of the data/content
  • 'date' is the datetime the request have been initiated
  • 'digest' is a checksum of the data/content
  • 'host' is the hostname of the recipient of the request (remote when signing outgoing request, local on incoming request)
  • 'Signature' contains the signature generated using the private key, and metadata:
    • 'keyId' is a unique id, formatted as an url. hostname is used to retrieve the public key via custom discovery
    • 'algorithm' define the algorithm used to generate signature
    • 'headers' contains a list of element used during the generation of the signature
    • 'signature' is the encrypted string, using local private key, of an array containing elements
      listed in 'headers' and their value. Some elements (content-length date digest host) are mandatory
      to ensure authenticity override protection.

(Those are the minimum required headers, some can be added via options during the process)

ISignatoryManager

Because each protocol have different ways to obtain the public key of a remote instance or entity, some part of the signing/verifying process is managed by a custom provider, one for each protocol.

  • getProviderId should returns a unique string

  • getOptions should returns an array that can contains those entries:

    • 'ttl' (300) is the lifetime (in secondes) of the signature
    • 'ttlSignatory' (86400*3) the cache lifetime on a remote signatory
    • 'extraSignatureHeaders' ([]) (list of extra headers to include to the signature)
    • 'algorithm' ('sha256') is the algorithm used to generate the signature
    • 'dateHeader' ("D, d M Y H:i:s T") the format of the 'date' header
  • getLocalSignatory should return the local signatory, including the full (public+private) key pair.

  • getRemoteSignatory should returns a remote signatory based on the requested data, must at least contains key id and public key

IKeyPairManager

IKeyPairManager contains a group of method to create/manage/store internal public/private key pair, stored as sensitive data using a lazy loaded IAppConfig variable. 2 strings app id and name are used to identify key pairs.

  • generateKeyPair generate and store public/private key pair.
  • hasKeyPair return if key pair is known in database.
  • getKeyPair return key pair from database based on app id and name.
  • deleteKeyPair delete key pair from database.

ISignatureManager

ISignatureManager is a service integrated to core that provide tools to set/get authenticity of/from outgoing/incoming requests.

  • getIncomingSignedRequest extract data from the incoming request and compare headers to confirm authenticity of remote instance
  • getOutgoingSignedRequest prep signature to sign an outgoing request.
  • signOutgoingRequestIClientPayload is the one method to call to fully process of signing and fulfilling the payload for an outgoing request using IClient
  • searchSignatory get a remote signatory from the database

lib/private/OCM/OCMSignatoryManager.php Fixed Show fixed Hide fixed
lib/private/OCM/OCMSignatoryManager.php Fixed Show fixed Hide fixed
lib/private/OCM/OCMSignatoryManager.php Fixed Show fixed Hide fixed
lib/private/OCM/OCMSignatoryManager.php Fixed Show fixed Hide fixed
lib/private/OCM/OCMSignatoryManager.php Fixed Show fixed Hide fixed

[$k, $v] = explode('=', $entry, 2);
preg_match('/"([^"]+)"/', $v, $varr);
if ($varr[0] !== null) {

Check failure

Code scanning / Psalm

RedundantCondition

string can never contain null
lib/private/OCM/OCMSignatoryManager.php Fixed Show fixed Hide fixed
lib/private/OCM/OCMSignatoryManager.php Fixed Show fixed Hide fixed

$parsed = parse_url($uri);
$signedRequest->setHost($parsed['host'])
->setAlgorithm($options['algorithm'] ?? 'sha256')

Check failure

Code scanning / Psalm

UndefinedVariable

Cannot find referenced variable $options
apps/files_sharing/lib/External/Cache.php Fixed Show fixed Hide fixed
// remote does not support signed request.
// currently we still accept unsigned request until lazy appconfig
// core.enforce_signed_ocm_request is set to true (default: false)
if ($this->appConfig->getValueBool('enforce_signed_ocm_request', false, lazy: true)) {

Check failure

Code scanning / Psalm

InvalidArgument

Argument 2 of OCP\IAppConfig::getValueBool cannot be false, string value expected
if ($signatory === null) {
throw new SignatoryNotFoundException('empty result from getRemoteSignatory');
}
if ($signatory->getKeyId() !== $signedRequest->getKeyId()) {

Check failure

Code scanning / Psalm

UndefinedInterfaceMethod

Method OCP\Security\Signature\Model\IIncomingSignedRequest::getKeyId does not exist
if (!str_contains($entry, '@')) {
throw new IncomingRequestException('entry does not contains @');
}
[, $instance] = explode('@', $entry, 2);

Check notice

Code scanning / Psalm

PossiblyUndefinedArrayOffset

Possibly undefined array key
* @inheritDoc
*
* @param string $publicKey
* @return IKeyPair

Check failure

Code scanning / Psalm

MismatchingDocblockReturnType

Docblock has incorrect return type 'OCP\Security\PublicPrivateKeyPairs\Model\IKeyPair', should be 'OC\Security\PublicPrivateKeyPairs\Model\KeyPair'
* @inheritDoc
*
* @param string $privateKey
* @return IKeyPair

Check failure

Code scanning / Psalm

MismatchingDocblockReturnType

Docblock has incorrect return type 'OCP\Security\PublicPrivateKeyPairs\Model\IKeyPair', should be 'OC\Security\PublicPrivateKeyPairs\Model\KeyPair'
* @inheritDoc
*
* @param array $options
* @return IKeyPair

Check failure

Code scanning / Psalm

MismatchingDocblockReturnType

Docblock has incorrect return type 'OCP\Security\PublicPrivateKeyPairs\Model\IKeyPair', should be 'OC\Security\PublicPrivateKeyPairs\Model\KeyPair'
* @inheritDoc
*
* @param string $host
* @return IOutgoingSignedRequest

Check failure

Code scanning / Psalm

MismatchingDocblockReturnType

Docblock has incorrect return type 'OCP\Security\Signature\Model\IOutgoingSignedRequest', should be 'OC\Security\Signature\Model\OutgoingSignedRequest'
* @param string $key
* @param string|int|float|bool|array $value
*
* @return IOutgoingSignedRequest

Check failure

Code scanning / Psalm

MismatchingDocblockReturnType

Docblock has incorrect return type 'OCP\Security\Signature\Model\IOutgoingSignedRequest', should be 'OC\Security\Signature\Model\OutgoingSignedRequest'
*
* @param string $estimated
*
* @return IOutgoingSignedRequest

Check failure

Code scanning / Psalm

MismatchingDocblockReturnType

Docblock has incorrect return type 'OCP\Security\Signature\Model\IOutgoingSignedRequest', should be 'OC\Security\Signature\Model\OutgoingSignedRequest'
*
* @param string $algorithm
*
* @return IOutgoingSignedRequest

Check failure

Code scanning / Psalm

MismatchingDocblockReturnType

Docblock has incorrect return type 'OCP\Security\Signature\Model\IOutgoingSignedRequest', should be 'OC\Security\Signature\Model\OutgoingSignedRequest'
* @inheritDoc
*
* @param array $signatureHeader
* @return ISignedRequest

Check failure

Code scanning / Psalm

MismatchingDocblockReturnType

Docblock has incorrect return type 'OCP\Security\Signature\Model\ISignedRequest', should be 'OC\Security\Signature\Model\SignedRequest'
/**
* @inheritDoc
*
* @return ISignatory

Check failure

Code scanning / Psalm

InvalidNullableReturnType

The declared return type 'OCP\Security\Signature\Model\ISignatory' for OC\Security\Signature\Model\SignedRequest::getSignatory is not nullable, but 'OCP\Security\Signature\Model\ISignatory|null' contains null
* @since 30.0.0
*/
public function getSignatory(): ISignatory {
return $this->signatory;

Check failure

Code scanning / Psalm

NullableReturnStatement

The declared return type 'OCP\Security\Signature\Model\ISignatory' for OC\Security\Signature\Model\SignedRequest::getSignatory is not nullable, but the function returns 'OCP\Security\Signature\Model\ISignatory|null'
@ArtificialOwl ArtificialOwl added the 3. to review Waiting for reviews label Jul 10, 2024
@ArtificialOwl ArtificialOwl added this to the Nextcloud 30 milestone Jul 10, 2024
@ArtificialOwl ArtificialOwl marked this pull request as ready for review July 10, 2024 16:24
to.json Outdated Show resolved Hide resolved
// if request is signed and well signed, no exception are thrown
// if request is not signed and host is known for not supporting signed request, no exception are thrown
$signedRequest = $this->getSignedRequest();
$this->confirmShareOrigin($signedRequest, $notification['sharedSecret'] ?? '');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sharedSecret inside the notification is already used by some OCM messages. So this would break it. Can you take another key? Or maybe you prefix with '$#$' or something alike?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this would not break it as I send the exception for the exact same reason of a missing 'sharedSecret' entry in the notifications request. The only thing is that I do this check earlier than others but I can add a prefix if you want (while I dont feel like necessary myself)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this would not break it as I send the exception for the exact same reason of a missing 'sharedSecret' entry in the notifications request.

Nextcloud Talk is sending a data field 'sharedSecret' and you either overwrite that and it breaks Talk Federation with older servers or you need to use a different key

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As said before, I only use this entry because it exists in the OCM protocol and doing so to compare that the origin of the reshare request, based on the token (and the linked recipient stored in the database), confirm the identity used to sign the request.

If Talk is using this endpoint to initiate anything, signature are to be required

lib/private/Federation/CloudFederationProviderManager.php Outdated Show resolved Hide resolved
lib/public/Security/Signature/SignatureAlgorithm.php Outdated Show resolved Hide resolved
lib/public/Security/Signature/Model/SignatoryType.php Outdated Show resolved Hide resolved
lib/private/Security/Signature/Model/SignedRequest.php Outdated Show resolved Hide resolved
lib/private/Security/Signature/SignatureManager.php Outdated Show resolved Hide resolved
core/Migrations/Version30000Date20240101084401.php Outdated Show resolved Hide resolved
}
}

private function insertSignatory(ISignatory $signatory): void {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By now our Entity+QBMapper pattern is widely adapted. Any reason why you didn't go for it and instead went back to doing all this manually again?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no real reason other that personal preference

lib/private/Security/Signature/SignatureManager.php Outdated Show resolved Hide resolved
This was referenced Jul 30, 2024
This was referenced Aug 5, 2024
@skjnldsv skjnldsv mentioned this pull request Aug 13, 2024
@skjnldsv skjnldsv modified the milestones: Nextcloud 30, Nextcloud 31 Aug 14, 2024
@skjnldsv skjnldsv added 2. developing Work in progress and removed 3. to review Waiting for reviews labels Oct 29, 2024
@ArtificialOwl ArtificialOwl force-pushed the enh/noid/signed-request branch 4 times, most recently from 444f9e7 to f3a1684 Compare October 31, 2024 12:21
@ArtificialOwl ArtificialOwl force-pushed the enh/noid/signed-request branch 2 times, most recently from 81d2a14 to c41e150 Compare November 8, 2024 11:46
@ArtificialOwl ArtificialOwl force-pushed the enh/noid/signed-request branch 7 times, most recently from 6c69420 to ec4ed86 Compare November 12, 2024 22:08
*
* @since 31.0.0
*/
class KeyPairManager implements IKeyPairManager {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apps/cloud_federation_api/lib/Capabilities.php Outdated Show resolved Hide resolved
$this->logger->warning('cannot generate local signatory', ['exception' => $e]);
}

return ['ocm' => json_decode(json_encode($this->provider->jsonSerialize()), true)];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the json_decode(json_encode( really needed?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is only requested for psalm as jsonSerialize returns serializable objects

Comment on lines 38 to 40
And As an "user1"
And sending "GET" to "/apps/files_sharing/api/v1/shares"
And the list of returned shares has 1 shares
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What change results in this being needed?

Comment on lines 229 to 239
// signing the payload using OCMSignatoryManager before initializing the request
$uri = $ocmProvider->getEndPoint() . '/notifications';
$payload = array_merge($this->getDefaultRequestOptions(), ['body' => json_encode($notification->getMessage())]);
if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
$signedPayload = $this->signatureManager->signOutgoingRequestIClientPayload(
$this->signatoryManager,
$payload,
'post', $uri
);
}
return $client->post($uri, $signedPayload ?? $payload);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

code is duplicated 4 times, please wrap it in a method.

Comment on lines 21 to 22
* KeyPairManager store internal public/private key pair using AppConfig, taking advantage of the encryption
* and lazy loading.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should use its own table, it’s a bad habit to put everything into appconfig.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is the main idea of appconfig to avoid creating a new table for only few entries, and the lazy loading helps keeping it light

* @since 31.0.0
*/
public function getIncomingSignedRequest(
ISignatoryManager $signatoryManager,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is that not injected? It creates a dependency loop?

* @return array new payload to be sent, including original payload and signature elements in headers
* @since 31.0.0
*/
public function signOutgoingRequestIClientPayload(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public function signOutgoingRequestIClientPayload(
public function signOutgoingRequestPayload(

Comment on lines +203 to +222
public function searchSignatory(string $host, string $account = ''): ISignatory {
$qb = $this->connection->getQueryBuilder();
$qb->select(
'id', 'provider_id', 'host', 'account', 'key_id', 'key_id_sum', 'public_key', 'metadata', 'type',
'status', 'creation', 'last_updated'
);
$qb->from(self::TABLE_SIGNATORIES);
$qb->where($qb->expr()->eq('host', $qb->createNamedParameter($host)));
$qb->andWhere($qb->expr()->eq('account', $qb->createNamedParameter($account)));

$result = $qb->executeQuery();
$row = $result->fetch();
$result->closeCursor();

if (!$row) {
throw new SignatoryNotFoundException('no signatory found');
}

$signature = new Signatory($row['key_id'], $row['public_key']);

return $signature->importFromDatabase($row);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there no mapper and entities for signatories?

Comment on lines +257 to +267
public function extractIdentityFromUri(string $uri): string {
$identity = parse_url($uri, PHP_URL_HOST);
$port = parse_url($uri, PHP_URL_PORT);
if ($identity === null || $identity === false) {
throw new IdentityNotFoundException('cannot extract identity from ' . $uri);
}

if ($port !== null && $port !== false) {
$identity .= ':' . $port;
}

return $identity;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have Déjà-vu about this code

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, fixed

@ArtificialOwl ArtificialOwl force-pushed the enh/noid/signed-request branch 5 times, most recently from 9983ea2 to d7446ed Compare November 16, 2024 18:22
Signed-off-by: Maxence Lange <[email protected]>
@ArtificialOwl ArtificialOwl force-pushed the enh/noid/signed-request branch 2 times, most recently from 6eb73da to 5912b44 Compare November 17, 2024 00:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
2. developing Work in progress
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants