diff --git a/src/SAML2/Artifact.php b/src/SAML2/Artifact.php new file mode 100644 index 000000000..1be6ef731 --- /dev/null +++ b/src/SAML2/Artifact.php @@ -0,0 +1,60 @@ +artifact; + } + + + /** + * Collect the value of the endpointIndex-property + * + * @return int + */ + public function getEndpointIndex(): int + { + return $this->endpointIndex; + } + + + /** + * Collect the value of the sourceId-property + * + * @return string + */ + public function getSourceId(): string + { + return $this->sourceId; + } +} diff --git a/src/SAML2/Entity/ServiceProvider.php b/src/SAML2/Entity/ServiceProvider.php new file mode 100644 index 000000000..0b82de79c --- /dev/null +++ b/src/SAML2/Entity/ServiceProvider.php @@ -0,0 +1,338 @@ +metadataProvider === null) { + throw new RuntimeException( + "A MetadataProvider is required to use the HTTP-Artifact binding.", + ); + } elseif ($this->storageProvider === null) { + throw new RuntimeException( + "A StorageProvider is required to use the HTTP-Artifact binding.", + ); + } + + $artifact = $b->receiveArtifact($request); + $this->idpMetadata = $this->metadataProvider->getIdPMetadataForSha1($artifact->getSourceId()); + + if ($this->idpMetadata === null) { + throw new MetadataNotFoundException(sprintf( + 'No metadata found for remote provider with SHA1 ID: %s', + $artifact->getSourceId(), + )); + } + + $b->setIdpMetadata($this->idpMetadata); + $b->setSPMetadata($this->spMetadata); + } + + $rawResponse = $b->receive($request); + Assert::isInstanceOf($rawResponse, Response::class, ResourceNotRecognizedException::class); // Wrong type if msg + + // Will return a raw Response prior to any form of verification + if ($this->bypassResponseValidation === true) { + return $rawResponse; + } + + // Verify the signature (if any) + $verifiedResponse = $rawResponse->isSigned() ? $this->verifyElementSignature($rawResponse) : $rawResponse; + + $state = null; + $stateId = $verifiedResponse->getInResponseTo(); + + if (!empty($stateId)) { + // this should be a response to a request we sent earlier + try { + $state = $this->stateProvider::loadState($stateId, 'saml:sp:sso'); + } catch (RuntimeException $e) { + // something went wrong, + Utils::getContainer()->getLogger()->warning(sprintf( + 'Could not load state specified by InResponseTo: %s Processing response as unsolicited.', + $e->getMessage(), + )); + } + } + + if ($state === null && $this->enableUnsolicited === false) { + throw new RequestDeniedException('Unsolicited responses are denied by configuration.'); + } + + // check that the issuer is the one we are expecting + Assert::keyExists($state, 'ExpectedIssuer'); + $issuer = $verfiedResponse->getIssuer()->getValue(); + + if ($state['ExpectedIssuer'] !== $issuer) { + throw new ResourceNotRecognizedException("Issuer doesn't match the one the AuthnRequest was sent to."); + } + + if ($this->metadataProvider === null) { + throw new RuntimeException( + "A MetadataProvider is required to be able to perform token decryption.", + ); + } + + $this->idpMetadata = $this->metadataProvider->getIdPMetadata($issuer); + if ($this->idpMetadata === null) { + throw new MetadataNotFoundException(sprintf( + 'No metadata found for remote identity provider with SHA1 ID: %s', + $issuer, + )); + } + + /** + * See paragraph 6.2 of the SAML 2.0 core specifications for the applicable processing rules + * + * Long story short - Decrypt the assertion first, then validate it's signature + * Once the signature is verified, decrypt any BaseID, NameID or Attribute that's encrypted + */ + $unverifiedAssertions = $verifiedResponse->getAssertions(); + $verifiedAssertions = []; + foreach ($verifiedResponse->getAssertions() as $i => $assertion) { + // Decrypt the assertions + $decryptedAssertion = ($assertion instanceof EncryptedAssertion) + ? $this->decryptElement($assertion) + : $assertion; + + // Verify the signature on the assertions (if any) + $verifiedAssertion = $this->verifyElementSignature($decryptedAssertion); + + // Decrypt the NameID and replace it inside the assertion's Subject + $nameID = $verifiedAssertion->getSubject()?->getIdentifier(); + + if ($nameID instanceof EncryptedID) { + $decryptedNameID = $this->decryptElement($nameID); + $subject = new Subject($decryptedNameID, $verifiedAssertion->getSubjectConfirmation()); + } else { + $subject = $verifiedAssertion->getSubject(); + } + + // Decrypt any occurrences of EncryptedAttribute and replace them inside the assertion's AttributeStatement + $statements = $verifiedAssertion->getStatements(); + foreach ($verifiedAssertion->getStatements() as $j => $statement) { + if ($statement instanceof AttributeStatement) { + $attributes = $statement->getAttributes(); + if ($statement->hasEncryptedAttributes()) { + foreach ($statement->getEncryptedAttributes() as $encryptedAttribute) { + $attributes[] = $this->decryptElement($encryptedAttribute); + } + } + + $statements[$j] = new AttributeStatement($attributes); + } + } + + $verifiedAssertions[] = new Assertion( + $verifiedAssertion->getIssuer(), + $verifiedAssertion->getIssueInstant(), + $verifiedAssertion->getID(), + $subject, + $verifiedAssertion->getConditions(), + $statements, + ); + } + + $decryptedResponse = new Response( + $verifiedResponse->getStatus(), + $verifiedResponse->getIssueInstant(), + $verifiedResponse->getIssuer(), + $verifiedResponse->getID(), + $verifiedResponse->getVersion(), + $verifiedResponse->getInResponseTo(), + $verifiedResponse->getDestination(), + $verifiedResponse->getConsent(), + $verifiedResponse->getExtensions(), + $verifiedAssertions, + ); + + // Validate that the destination matches the appropriate endpoint from the SP-metadata + $this->validateResponseDestination($b, $decryptedResponse); + + // TODO: Refactor response- and assertion-validators and run them here + // Validate that the issuer matches an entity we know + if (!($b instanceof HTTPArtifact)) { + $idpMetadata = $this->metadataProvider->getIdPMetadata($decryptedResponse->getEntityId()); + } + + // Validate that the status is 'success' + $this->validateResponseStatus($decryptedResponse); + + // TODO: Validate assertions + + return $decryptedResponse; + } + + + /** + * Decrypt the given element using the decryption keys provided to us. + * + * @param \SimpleSAML\XMLSecurity\XML\EncryptedElementInterface $element + * @return \SimpleSAML\XMLSecurity\EncryptableElementInterface + * + * @throws \SimpleSAML\SAML2\Exception\RuntimeException if none of the keys could be used to decrypt the element + */ + protected function decryptElement(EncryptedElementInterface $element): EncryptableElementInterface + { + $factory = $this->spMetadata->getEncryptionAlgorithmFactory(); + + $encryptionAlgorithm = ($factory instanceof EncryptionAlgorithmFactory) + ? $element->getEncryptedData()->getEncryptionMethod() + : $element->getEncryptedKey()->getEncryptionMethod(); + + foreach ($this->spMetadata->getDecriptionKeys() as $decryptionKey) { + $decryptor = $factory->getAlgorithm($encryptionAlgorithm, $decryptionKey); + try { + return $element->decrypt($decryptor); + } catch (Exception $e) { + continue; + } + } + + throw new RuntimeException(sprintf( + 'Unable to decrypt %s with any of the available keys.', + $element::class, + )); + } + + + /** + * Validate the status of the received response. + * + * @param \SimpleSAML\SAML2\XML\samlp\Response $response + */ + protected function validateResponseStatus(Response $response): void + { + if (!$response->isSuccess()) { + throw new RemoteException($response->getStatus()); + } + } + + + /** + * Validate the destination of the received response. + * + * @param \SimpleSAML\SAML2\Binding $binding + * @param \SimpleSAML\SAML2\XML\samlp\Response $response + * @throws \SimpleSAML\SAML2\Exception\DestinationMismatchException + */ + protected function validateResponseDestination(Binding $b, Response $response): void + { + foreach ($this->spMetadata->getAssertionConsumerService() as $assertionConsumerService) { + if ($assertionConsumerService->getLocation() === $response->getDestination()) { + if (Binding::getBinding($assertionConsumerService->getBinding()) instanceof $b) { + return; + } + } + } + + throw new ResourceNotRecognizedException(); + } + + + /** + * Verify the signature of an element using the available validation keys. + * + * @param \SimpleSAML\XMLSecurity\XML\SignedElementInterface $element + * @return \SimpleSAML\XMLSecurity\XML\SignableElementInterface The validated element. + * + * @throws \SimpleSAML\XMLSecurity\Exception\SignatureVerificationFailedException + */ + protected function verifyElementSignature(SignedElementInterface $element): SignableElementInterface + { + $factory = $this->spMetadata->getSignatureAlgorithmFactory(); + $signatureAlgorithm = $element->getSignature()->getSignedInfo()->getSignatureMethod()->getAlgorithm(); + + foreach ($this->spMetadata->getValidatingKeys() as $validatingKey) { + $verifier = $factory->getAlgorithm($signatureAlgorithm, $validatingKey); + + try { + return $element->verify($verifier); + } catch (SignatureVerificationFailedException $e) { + continue; + } + } + + throw new SignatureVerificationFailedException(); + } +} diff --git a/src/SAML2/Exception/MetadataNotFoundException.php b/src/SAML2/Exception/MetadataNotFoundException.php new file mode 100644 index 000000000..db23bbee6 --- /dev/null +++ b/src/SAML2/Exception/MetadataNotFoundException.php @@ -0,0 +1,12 @@ +getStatusCode(); + $message = $statusCode->getValue(); + + // Until proven necessary, we go just one level deep + foreach ($statusCode->getSubCode() as $subCode) { + $message = sprintf("%s / %s", $message, $subCode->getValue()); + } + + $message = sprintf("%s (%s)", $message, $status->getStatusMessage()->getValue()); + + parent::__construct($message); + } +} diff --git a/src/SAML2/HTTPArtifact.php b/src/SAML2/HTTPArtifact.php index 855e5e152..42a2c9de3 100644 --- a/src/SAML2/HTTPArtifact.php +++ b/src/SAML2/HTTPArtifact.php @@ -11,27 +11,24 @@ use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Assert\Assert; use SimpleSAML\Configuration; -use SimpleSAML\Metadata\MetaDataStorageHandler; use SimpleSAML\Module\saml\Message as MSG; +use SimpleSAML\SAML2\Metadata; use SimpleSAML\SAML2\Utils; use SimpleSAML\SAML2\XML\saml\Issuer; use SimpleSAML\SAML2\XML\samlp\AbstractMessage; use SimpleSAML\SAML2\XML\samlp\ArtifactResolve; -use SimpleSAML\SAML2\XML\samlp\ArtifactResponse; use SimpleSAML\Store\StoreFactory; use SimpleSAML\Utils\HTTP; -use SimpleSAML\XMLSecurity\XMLSecurityKey; use function array_key_exists; use function base64_decode; use function base64_encode; use function bin2hex; -use function hexdec; +use function bindec; use function openssl_random_pseudo_bytes; use function pack; use function sha1; use function substr; -use function var_export; /** * Class which implements the HTTP-Artifact binding. @@ -40,12 +37,9 @@ */ class HTTPArtifact extends Binding { - /** - * @psalm-suppress UndefinedDocblockClass - * @psalm-suppress UndefinedClass - * @var \SimpleSAML\Configuration - */ - private Configuration $spMetadata; + private Metadata\IdentityProvider $idpMetadata; + private Metadata\ServiceProvider $spMetadata; + private Artifact $artifact; /** @@ -109,6 +103,22 @@ public function send(AbstractMessage $message): ResponseInterface } + public function receiveArtifact(ServerRequestInterface $request): Artifact + { + $query = $request->getQueryParams(); + if (array_key_exists('SAMLart', $query)) { + $artifact = base64_decode($query['SAMLart'], true); + $endpointIndex = bindec(substr($artifact, 2, 2)); + $sourceId = bin2hex(substr($artifact, 4, 20)); + + $this->artifact = new Artifact($artifact, $endpointIndex, $sourceId); + return $this->artifact; + } + + throw new Exception('Missing SAMLart parameter.'); + } + + /** * Receive a SAML 2 message sent using the HTTP-Artifact binding. * @@ -122,29 +132,15 @@ public function send(AbstractMessage $message): ResponseInterface */ public function receive(ServerRequestInterface $request): AbstractMessage { - $query = $request->getQueryParams(); - if (array_key_exists('SAMLart', $query)) { - $artifact = base64_decode($query['SAMLart'], true); - $endpointIndex = bin2hex(substr($artifact, 2, 2)); - $sourceId = bin2hex(substr($artifact, 4, 20)); - } else { - throw new Exception('Missing SAMLart parameter.'); - } - - /** @psalm-suppress UndefinedClass */ - $metadataHandler = MetaDataStorageHandler::getMetadataHandler(Configuration::getInstance()); - - $idpMetadata = $metadataHandler->getMetaDataConfigForSha1($sourceId, 'saml20-idp-remote'); - - if ($idpMetadata === null) { - throw new Exception('No metadata found for remote provider with SHA1 ID: ' . var_export($sourceId, true)); - } + $idpMetadata = $this->idPMetadata; $endpoint = null; - foreach ($idpMetadata->getEndpoints('ArtifactResolutionService') as $ep) { - if ($ep['index'] === hexdec($endpointIndex)) { - $endpoint = $ep; - break; + foreach ($idpMetadata->getAssertionConsumerService() as $assertionConsumerService) { + if (Binding::getBinding($assertionConsumerService->getBinding()) instanceof HTTPArtifact) { + if ($assertionConsumerService->getIndex() === $this->artifact->getEndpointIndex()) { + $endpoint = $ep; + break; + } } } @@ -202,26 +198,19 @@ public function receive(ServerRequestInterface $request): AbstractMessage /** - * @param \SimpleSAML\Configuration $sp - * - * @psalm-suppress UndefinedClass + * @param \SimpleSAML\SAML2\Metadata\ServiceProvider $spMetadata */ - public function setSPMetadata(Configuration $sp): void + public function setSPMetadata(Metadata\ServiceProvider $spMetadata): void { - $this->spMetadata = $sp; + $this->spMetadata = $spMetadata; } /** - * A validator which returns true if the ArtifactResponse was signed with the given key - * - * @param \SimpleSAML\SAML2\XML\samlp\ArtifactResponse $message - * @param \SimpleSAML\XMLSecurity\XMLSecurityKey $key - * @return bool + * @param \SimpleSAML\SAML2\Metadata\IdentityProvider $idpMetadata */ - public static function validateSignature(ArtifactResponse $message, XMLSecurityKey $key): bool + public function setIdPMetadata(Metadata\IdentityProvider $idpMetadata): void { - // @todo verify if this works and/or needs to do anything more. Ref. HTTPRedirect binding - return $message->validate($key); + $this->idpMetadata = $idpMetadata; } } diff --git a/src/SAML2/Metadata/AbstractProvider.php b/src/SAML2/Metadata/AbstractProvider.php new file mode 100644 index 000000000..12ce0baed --- /dev/null +++ b/src/SAML2/Metadata/AbstractProvider.php @@ -0,0 +1,130 @@ +signatureAlgorithmFactory; + } + + + /** + * Retrieve the EncryptionAlgorithmFactory used for encrypting and decrypting messages. + */ + public function getEncryptionAlgorithmFactory(): EncryptionAlgorithmFactory|KeyTransportAlgorithmFactory|null + { + return $this->encryptionAlgorithmFactory; + } + + + /** + * Retrieve the signature slgorithm to be used for signing messages. + */ + public function getSignatureAlgorithm(): string + { + return $this->signatureAlgorithm; + } + + + /** + * Get the private key to use for signing messages. + * + * @return \SimpleSAML\XMLSecurity\Key\PrivateKey|null + */ + public function getSigningKey(): ?PrivateKey + { + return $this->signingKey; + } + + + /** + * Get the validating keys to verify a message signature with. + * + * @return array<\SimpleSAML\XMLSecurity\Key\PublicKey> + */ + public function getValidatingKeys(): array + { + return $this->validatingKeys; + } + + + /** + * Get the private key to use for signing messages. + * + * @return \SimpleSAML\XMLSecurity\Key\PublicKey|\SimpleSAML\XMLSecurity\Key\SymmetricKey|null + */ + public function getEncryptionKey(): PublicKey|SymmetricKey|null + { + return $this->encryptionKey; + } + + + /** + * Get the decryption keys to decrypt the assertion with. + * + * @return array<\SimpleSAML\XMLSecurity\Key\PrivateKey|\SimpleSAML\XMLSecurity\Key\SymmetricKey> + */ + public function getDecryptionKeys(): array + { + return $this->decryptionKeys; + } + + + /** + * Retrieve the configured entity ID for this entity + */ + public function getEntityId(): string + { + return $this->entityId; + } + + + /** + * Retrieve the configured IDPList for this entity. + * + * @return string[] + */ + public function getIDPList(): array + { + return $this->IDPList; + } +} diff --git a/src/SAML2/Metadata/IdentityProvider.php b/src/SAML2/Metadata/IdentityProvider.php new file mode 100644 index 000000000..390ae835c --- /dev/null +++ b/src/SAML2/Metadata/IdentityProvider.php @@ -0,0 +1,45 @@ + + */ + public function getAssertionConsumerService(): array + { + return $this->assertionConsumerService; + } +} diff --git a/src/SAML2/StateProviderInterface.php b/src/SAML2/StateProviderInterface.php new file mode 100644 index 000000000..45b11f98b --- /dev/null +++ b/src/SAML2/StateProviderInterface.php @@ -0,0 +1,24 @@ +