├── .prettierrc ├── example ├── src │ ├── app.conf.php │ ├── Template.php │ ├── LogWriter.php │ ├── Config.php │ ├── Pages.php │ ├── Router.php │ ├── Logger.php │ └── Auth.php ├── tpl │ ├── welcome.phtml │ ├── site.phtml │ └── login.phtml ├── public │ ├── .htaccess │ ├── index.php │ └── js │ │ └── errors.js ├── Dockerfile.PhpFpm ├── composer.json └── Dockerfile.Apache ├── src ├── util │ ├── HashAlgorithm.php │ ├── TrustedCertificates.php │ ├── SecureRandom.php │ ├── DefaultClock.php │ ├── DateAndTime.php │ ├── CollectionsUtil.php │ └── AsnUtil.php ├── validator │ ├── ocsp │ │ ├── OcspClient.php │ │ ├── service │ │ │ ├── OcspService.php │ │ │ ├── AiaOcspServiceConfiguration.php │ │ │ ├── DesignatedOcspService.php │ │ │ ├── DesignatedOcspServiceConfiguration.php │ │ │ └── AiaOcspService.php │ │ ├── OcspUrl.php │ │ ├── OcspRequestBuilder.php │ │ ├── OcspServiceProvider.php │ │ ├── OcspClientImpl.php │ │ └── OcspResponseValidator.php │ ├── certvalidators │ │ ├── SubjectCertificateValidator.php │ │ ├── SubjectCertificateValidatorBatch.php │ │ ├── SubjectCertificateTrustedValidator.php │ │ ├── SubjectCertificatePolicyValidator.php │ │ └── SubjectCertificatePurposeValidator.php │ ├── AuthTokenValidator.php │ ├── AuthTokenSignatureValidator.php │ └── AuthTokenValidationConfiguration.php ├── exceptions │ ├── ChallengeNullOrEmptyException.php │ ├── CertificateDecodingException.php │ ├── AuthTokenSignatureValidationException.php │ ├── UserCertificateParseException.php │ ├── AuthTokenParseException.php │ ├── ChallengeNonceExpiredException.php │ ├── ChallengeNonceGenerationException.php │ ├── SessionDoesNotExistException.php │ ├── CertificateExpiredException.php │ ├── CertificateNotYetValidException.php │ ├── UserCertificateMissingPurposeException.php │ ├── ChallengeNonceNotFoundException.php │ ├── UserCertificateWrongPurposeException.php │ ├── UserCertificateDisallowedPolicyException.php │ ├── OCSPCertificateException.php │ ├── UserCertificateOCSPCheckFailedException.php │ ├── CertificateNotTrustedException.php │ ├── UserCertificateRevokedException.php │ └── AuthTokenException.php ├── ocsp │ ├── exceptions │ │ ├── OcspCertificateException.php │ │ ├── OcspVerifyFailedException.php │ │ ├── OcspResponseDecodeException.php │ │ └── OcspException.php │ ├── maps │ │ ├── OcspResponseMap.php │ │ └── OcspRequestMap.php │ ├── OcspRequest.php │ ├── certificate │ │ └── CertificateLoader.php │ ├── Ocsp.php │ ├── OcspBasicResponse.php │ └── OcspResponse.php ├── challenge │ ├── ChallengeNonceGenerator.php │ ├── ChallengeNonce.php │ ├── ChallengeNonceGeneratorImpl.php │ ├── ChallengeNonceStore.php │ └── ChallengeNonceGeneratorBuilder.php ├── certificate │ ├── SubjectCertificatePolicies.php │ ├── CertificateLoader.php │ ├── CertificateData.php │ └── CertificateValidator.php └── authtoken │ └── WebEidAuthToken.php ├── .github └── workflows │ └── php.yml ├── LICENSE └── composer.json /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@prettier/plugin-php"] 3 | } -------------------------------------------------------------------------------- /example/src/app.conf.php: -------------------------------------------------------------------------------- 1 | 'https://localhost', 5 | ]; -------------------------------------------------------------------------------- /example/tpl/welcome.phtml: -------------------------------------------------------------------------------- 1 |
62 | * When the time-to-live passes, the nonce is considered to be expired.
63 | *
64 | * @copyright 2022 Petr Muzikant pmuzikant@email.cz
65 | *
66 | * @param int $seconds - challenge nonce time-to-live duration in seconds
67 | * @return ChallengeNonceGeneratorBuilder builder instance
68 | */
69 | public function withNonceTtl(int $seconds): ChallengeNonceGeneratorBuilder
70 | {
71 | $this->ttl = $seconds;
72 | return $this;
73 | }
74 |
75 | /**
76 | * Sets the challenge nonce store where the generated challenge nonces will be stored.
77 | *
78 | * @param challengeNonceStore challenge nonce store
79 | * @return ChallengeNonceGeneratorBuilder builder instance
80 | */
81 | public function withChallengeNonceStore(ChallengeNonceStore $challengeNonceStore): ChallengeNonceGeneratorBuilder
82 | {
83 | $this->challengeNonceStore = $challengeNonceStore;
84 | return $this;
85 | }
86 |
87 | /**
88 | * Sets the source of random bytes for the nonce.
89 | *
90 | * @param callable $secureRandom function which returns random bytes with number of bytes as input
91 | *
92 | * @return ChallengeNonceGeneratorBuilder builder instance
93 | */
94 | public function withSecureRandom(callable $secureRandom): ChallengeNonceGeneratorBuilder
95 | {
96 | $this->secureRandom = $secureRandom;
97 | return $this;
98 | }
99 |
100 | /**
101 | * Validates the configuration and builds the {@link ChallengeNonceGenerator} instance.
102 | *
103 | * @return new challenge nonce generator instance
104 | */
105 | public function build(): ChallengeNonceGenerator
106 | {
107 | // Force to use PHP session based nounce store when store is not set
108 | if (!isset($this->challengeNonceStore)) {
109 | $this->challengeNonceStore = new ChallengeNonceStore();
110 | }
111 | $this->validateParameters();
112 | return new ChallengeNonceGeneratorImpl($this->challengeNonceStore, $this->secureRandom, $this->ttl);
113 | }
114 |
115 | private function validateParameters(): void
116 | {
117 | if (is_null($this->challengeNonceStore)) {
118 | throw new InvalidArgumentException("Challenge nonce store must not be null");
119 | }
120 | if (is_null($this->secureRandom)) {
121 | throw new InvalidArgumentException("Secure random generator must not be null");
122 | }
123 | DateAndTime::requirePositiveDuration($this->ttl, "Nonce TTL");
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/src/validator/AuthTokenSignatureValidator.php:
--------------------------------------------------------------------------------
1 | siteOrigin = $siteOrigin;
58 | }
59 |
60 | public function validate(string $algorithm, string $signature, $publicKey, string $currentChallengeNonce): void
61 | {
62 | if (empty($currentChallengeNonce)) {
63 | throw new ChallengeNullOrEmptyException();
64 | }
65 |
66 | if (is_null($publicKey)) {
67 | throw new InvalidArgumentException("Public key is null");
68 | }
69 |
70 | $this->requireNotEmpty($algorithm, "algorithm");
71 | $this->requireNotEmpty($signature, "signature");
72 |
73 | if (!in_array($algorithm, self::ALLOWED_SIGNATURE_ALGORITHMS)) {
74 | throw new AuthTokenParseException("Unsupported signature algorithm");
75 | }
76 |
77 | $decodedSignature = base64_decode($signature);
78 |
79 | // Note that in case of ECDSA, some eID cards output raw R||S, so we need to trascode it to DER
80 | if (in_array($algorithm, self::ECDSA_ALGORITHMS) && !AsnUtil::isSignatureInAsn1Format($decodedSignature)) {
81 | $decodedSignature = AsnUtil::transcodeSignatureToDER($decodedSignature);
82 | }
83 |
84 | if (in_array($algorithm, self::RSASSA_PSS_ALGORITHMS)) {
85 | $publicKey = openssl_get_publickey($publicKey->withPadding(RSA::SIGNATURE_PSS)->toString('PSS'));
86 | if (!$publicKey) {
87 | throw new AuthTokenParseException('Could not use PSS padding for RSASSA-PSS algorithm');
88 | }
89 | }
90 |
91 | $hashAlgorithm = $this->hashAlgorithmForName($algorithm);
92 |
93 | $originHash = openssl_digest($this->siteOrigin->jsonSerialize(), $hashAlgorithm, true);
94 | $nonceHash = openssl_digest($currentChallengeNonce, $hashAlgorithm, true);
95 | $concatSignedFields = $originHash . $nonceHash;
96 |
97 | $result = openssl_verify($concatSignedFields, $decodedSignature, $publicKey, $hashAlgorithm);
98 | if ($result !== 1) {
99 | throw new AuthTokenSignatureValidationException($result === -1 ? openssl_error_string() : "Signature is invalid");
100 | }
101 | }
102 |
103 | private function hashAlgorithmForName(string $algorithm): string
104 | {
105 | return "sha" . substr($algorithm, -3);
106 | }
107 |
108 | private function requireNotEmpty(string $argument, string $fieldName): void
109 | {
110 | if (empty($argument)) {
111 | throw new AuthTokenParseException("'" . $fieldName . "' is null or empty");
112 | }
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/src/util/CollectionsUtil.php:
--------------------------------------------------------------------------------
1 | array[$offset]);
51 | }
52 |
53 | #[\ReturnTypeWillChange]
54 | public function offsetGet(mixed $offset): mixed
55 | {
56 | return $this->array[$offset];
57 | }
58 |
59 | public function offsetSet(mixed $offset, mixed $value): void
60 | {
61 | $this->validateType($value);
62 | $this->array[$offset] = $value;
63 | }
64 |
65 | public function offsetUnset(mixed $offset): void
66 | {
67 | unset($this->array[$offset]);
68 | }
69 |
70 | public function count(): int
71 | {
72 | return count($this->array);
73 | }
74 |
75 | public function getIterator(): ArrayIterator
76 | {
77 | return new ArrayIterator($this->array);
78 | }
79 |
80 | public function pushItem($value): void
81 | {
82 | $this->validateType($value);
83 | array_push($this->array, $value);
84 | }
85 | }
86 |
87 | class X509Collection extends Collection
88 | {
89 | public function __construct(X509 ...$certificates)
90 | {
91 | $this->array = $certificates;
92 | }
93 |
94 | public function validateType($value): void
95 | {
96 | if (!$value instanceof X509) {
97 | throw new TypeError("Wrong type, expected " . X509::class);
98 | }
99 | }
100 |
101 | // For logging purpose
102 | public static function getSubjectDNs(?X509Collection $x509Collection, X509 ...$certificates): array
103 | {
104 | $array = is_null($x509Collection) ? $certificates : $x509Collection;
105 | $subjectDNs = [];
106 | foreach ($array as $certificate) {
107 | $subjectDNs[] = $certificate->getSubjectDN(X509::DN_STRING);
108 | }
109 | return $subjectDNs;
110 | }
111 | }
112 |
113 | class SubjectCertificateValidatorCollection extends Collection
114 | {
115 | public function __construct(SubjectCertificateValidator ...$validators)
116 | {
117 | $this->array = $validators;
118 | }
119 |
120 | public function validateType($value): void
121 | {
122 | if (!$value instanceof SubjectCertificateValidator) {
123 | throw new TypeError("Wrong type, expected " . SubjectCertificateValidator::class);
124 | }
125 | }
126 | }
127 |
128 | class UriCollection extends Collection
129 | {
130 | public function __construct(Uri ...$urls)
131 | {
132 | $this->array = $urls;
133 | }
134 |
135 | public function validateType($value): void
136 | {
137 | if (!$value instanceof Uri) {
138 | throw new TypeError("Wrong type, expected " . Uri::class);
139 | }
140 | }
141 |
142 | public function getUrls(): array
143 | {
144 | $result = [];
145 | foreach ($this->array as $uri) {
146 | $result[] = $uri;
147 | }
148 | return $result;
149 | }
150 |
151 | public function getUrlsArray(): array
152 | {
153 | $result = [];
154 | foreach ($this->array as $uri) {
155 | $result[] = $uri->jsonSerialize();
156 | }
157 | return $result;
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/src/ocsp/certificate/CertificateLoader.php:
--------------------------------------------------------------------------------
1 | loadX509($fileContent);
55 | if (!$loaded) {
56 | throw new OcspCertificateException(
57 | "Certificate decoding from Base64 or parsing failed for " .
58 | $pathToFile
59 | );
60 | }
61 | $this->certificate = $certificate;
62 | return $this;
63 | }
64 |
65 | /**
66 | * Loads the certificate from string and returns the certificate
67 | *
68 | * @param string certString - certificate as string
69 | * @throws OcspCertificateException when the certificate decoding or parse fails
70 | */
71 | public function fromString(string $certString)
72 | {
73 | $certificate = new X509();
74 | $loaded = false;
75 | try {
76 | $loaded = $certificate->loadX509($certString);
77 | } catch (Exception $e) {
78 | }
79 | if (!$loaded) {
80 | throw new OcspCertificateException(
81 | "Certificate decoding from Base64 or parsing failed"
82 | );
83 | }
84 | $this->certificate = $certificate;
85 | return $this;
86 | }
87 |
88 | public function getIssuerCertificateUrl(): string
89 | {
90 | if (!$this->certificate) {
91 | throw new OcspCertificateException("Certificate not loaded");
92 | }
93 |
94 | $url = "";
95 | $opts = $this->certificate->getExtension("id-pe-authorityInfoAccess");
96 | foreach ($opts as $opt) {
97 | if ($opt["accessMethod"] == "id-ad-caIssuers") {
98 | $url = $opt["accessLocation"]["uniformResourceIdentifier"];
99 | break;
100 | }
101 | }
102 | return $url;
103 | }
104 |
105 | public function getOcspResponderUrl(): string
106 | {
107 | if (!$this->certificate) {
108 | throw new OcspCertificateException("Certificate not loaded");
109 | }
110 |
111 | $url = "";
112 | $opts = $this->certificate->getExtension("id-pe-authorityInfoAccess");
113 | foreach ($opts as $opt) {
114 | if ($opt["accessMethod"] == "id-ad-ocsp" || $opt["accessMethod"] == "id-pkix-ocsp") {
115 | $url = $opt["accessLocation"]["uniformResourceIdentifier"];
116 | break;
117 | }
118 | }
119 | return $url;
120 | }
121 |
122 | public function getCert(): X509
123 | {
124 | if (!$this->certificate) {
125 | throw new OcspCertificateException("Certificate not loaded");
126 | }
127 | return $this->certificate;
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/src/ocsp/Ocsp.php:
--------------------------------------------------------------------------------
1 | [],
76 | "issuerNameHash" => "",
77 | "issuerKeyHash" => "",
78 | "serialNumber" => [],
79 | ];
80 |
81 | if (
82 | !isset(
83 | $certificate->getCurrentCert()["tbsCertificate"]["serialNumber"]
84 | )
85 | ) {
86 | // Serial number of subject certificate does not exist
87 | throw new OcspCertificateException(
88 | "Serial number of subject certificate does not exist"
89 | );
90 | }
91 |
92 | $certificateId["serialNumber"] = clone $certificate->getCurrentCert()["tbsCertificate"]["serialNumber"];
93 |
94 | // issuer name
95 | if (
96 | !isset(
97 | $issuerCertificate->getCurrentCert()["tbsCertificate"][
98 | "subject"
99 | ]
100 | )
101 | ) {
102 | // Serial number of issuer certificate does not exist
103 | throw new OcspCertificateException(
104 | "Serial number of issuer certificate does not exist"
105 | );
106 | }
107 |
108 | $issuer = $issuerCertificate->getCurrentCert()["tbsCertificate"][
109 | "subject"
110 | ];
111 | $issuerEncoded = ASN1::encodeDER($issuer, Name::MAP);
112 | $certificateId["issuerNameHash"] = hash($hashAlgorithm->value, $issuerEncoded, true);
113 |
114 | // issuer public key
115 | if (
116 | !isset(
117 | $issuerCertificate->getCurrentCert()["tbsCertificate"][
118 | "subjectPublicKeyInfo"
119 | ]["subjectPublicKey"]
120 | )
121 | ) {
122 | // SubjectPublicKey of issuer certificate does not exist
123 | throw new OcspCertificateException(
124 | "SubjectPublicKey of issuer certificate does not exist"
125 | );
126 | }
127 |
128 | $publicKey = $issuerCertificate->getCurrentCert()["tbsCertificate"][
129 | "subjectPublicKeyInfo"
130 | ]["subjectPublicKey"];
131 | $certificateId["issuerKeyHash"] = hash(
132 | $hashAlgorithm->value,
133 | AsnUtil::extractKeyData($publicKey),
134 | true
135 | );
136 |
137 | $certificateId["hashAlgorithm"]["algorithm"] = Asn1::getOID("id-" . $hashAlgorithm->value);
138 |
139 | return $certificateId;
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/src/ocsp/maps/OcspRequestMap.php:
--------------------------------------------------------------------------------
1 | ASN1::TYPE_SEQUENCE,
44 | "children" => [
45 | "tbsRequest" => [
46 | "type" => ASN1::TYPE_SEQUENCE,
47 | "children" => [
48 | "version" => [
49 | "constant" => 0,
50 | "explicit" => true,
51 | "optional" => true,
52 | "mapping" => [0 => "v1"],
53 | "default" => "v1",
54 | "type" => ASN1::TYPE_INTEGER,
55 | ],
56 | "requestList" => [
57 | "type" => ASN1::TYPE_SEQUENCE,
58 | "min" => 0,
59 | "max" => -1,
60 | "children" => [
61 | "type" => ASN1::TYPE_SEQUENCE,
62 | "children" => [
63 | "reqCert" => [
64 | "type" => ASN1::TYPE_SEQUENCE,
65 | "children" => [
66 | "hashAlgorithm" =>
67 | AlgorithmIdentifier::MAP,
68 | "issuerNameHash" => [
69 | "type" => ASN1::TYPE_OCTET_STRING,
70 | ],
71 | "issuerKeyHash" => [
72 | "type" => ASN1::TYPE_OCTET_STRING,
73 | ],
74 | "serialNumber" =>
75 | CertificateSerialNumber::MAP,
76 | ],
77 | ],
78 | "singleRequestExtensions" =>
79 | [
80 | "constant" => 0,
81 | "explicit" => true,
82 | "optional" => true,
83 | ] + Extensions::MAP,
84 | ],
85 | ],
86 | ],
87 | "requestExtensions" =>
88 | [
89 | "constant" => 2,
90 | "explicit" => true,
91 | "optional" => true,
92 | ] + Extensions::MAP,
93 | "requestorName" =>
94 | [
95 | "constant" => 1,
96 | "optional" => true,
97 | "explicit" => true,
98 | ] + GeneralName::MAP,
99 | ],
100 | ],
101 | "optionalSignature" => [
102 | "constant" => 0,
103 | "explicit" => true,
104 | "optional" => true,
105 | "type" => ASN1::TYPE_SEQUENCE,
106 | "children" => [
107 | "signatureAlgorithm" => AlgorithmIdentifier::MAP,
108 | "signature" => ["type" => ASN1::TYPE_BIT_STRING],
109 | "certs" => [
110 | "constant" => 0,
111 | "explicit" => true,
112 | "optional" => true,
113 | "type" => ASN1::TYPE_SEQUENCE,
114 | "min" => 0,
115 | "max" => -1,
116 | "children" => Certificate::MAP,
117 | ],
118 | ],
119 | ],
120 | ],
121 | ];
122 | }
123 |
--------------------------------------------------------------------------------
/src/ocsp/OcspBasicResponse.php:
--------------------------------------------------------------------------------
1 | ocspBasicResponse = $ocspBasicResponse;
44 | }
45 |
46 | public function getResponses(): array
47 | {
48 | return $this->ocspBasicResponse["tbsResponseData"]["responses"];
49 | }
50 |
51 | /**
52 | * @copyright 2022 Petr Muzikant pmuzikant@email.cz
53 | */
54 | public function getCertificates(): array
55 | {
56 | $certificatesArr = [];
57 | if (isset($this->ocspBasicResponse["certs"])) {
58 | foreach ($this->ocspBasicResponse["certs"] as $cert) {
59 | $x509 = new X509();
60 | /*
61 | We need to DER encode each responder certificate array as there exists some
62 | more loading in X509->loadX509 method, which is not executed when loading just basic array.
63 | For example without this the publicKey would not be in PEM format and X509->getPublicKey()
64 | will throw error. It also maps out the extensions from BIT STRING
65 | */
66 | $x509->loadX509(ASN1::encodeDER($cert, Certificate::MAP));
67 | $certificatesArr[] = $x509;
68 | }
69 | unset($x509);
70 | }
71 |
72 | return $certificatesArr;
73 | }
74 |
75 | public function getSignature(): string
76 | {
77 | $signature = $this->ocspBasicResponse["signature"];
78 | return pack("c*", ...array_slice(unpack("c*", $signature), 1));
79 | }
80 |
81 | public function getProducedAt(): DateTime
82 | {
83 | return new DateTime(
84 | $this->ocspBasicResponse["tbsResponseData"]["producedAt"]
85 | );
86 | }
87 |
88 | public function getThisUpdate(): DateTime
89 | {
90 | return new DateTime($this->getResponses()[0]["thisUpdate"]);
91 | }
92 |
93 | public function getNextUpdate(): ?DateTime
94 | {
95 | if (isset($this->getResponses()[0]["nextUpdate"])) {
96 | return new DateTime($this->getResponses()[0]["nextUpdate"]);
97 | }
98 | return null;
99 | }
100 |
101 | /**
102 | * @copyright 2022 Petr Muzikant pmuzikant@email.cz
103 | */
104 | public function getSignatureAlgorithm(): string
105 | {
106 | $algorithm = strtolower(
107 | $this->ocspBasicResponse["signatureAlgorithm"]["algorithm"]
108 | );
109 |
110 | if (false !== ($pos = strpos($algorithm, "sha3-"))) {
111 | return substr($algorithm, $pos, 8);
112 | }
113 | if (false !== ($pos = strpos($algorithm, "sha"))) {
114 | return substr($algorithm, $pos, 6);
115 | }
116 |
117 | throw new OcspCertificateException(
118 | "Signature algorithm " . $algorithm . " not implemented"
119 | );
120 | }
121 |
122 | public function getNonceExtension(): ?string
123 | {
124 | return AsnUtil::decodeNonceExtension($this->ocspBasicResponse["tbsResponseData"]["responseExtensions"]);
125 | }
126 |
127 | public function getCertID(): array
128 | {
129 | $certStatusResponse = $this->getResponses()[0];
130 | // Translate algorithm name to OID for correct equality check
131 | $certStatusResponse["certID"]["hashAlgorithm"][
132 | "algorithm"
133 | ] = ASN1::getOID(
134 | $certStatusResponse["certID"]["hashAlgorithm"]["algorithm"]
135 | );
136 | return $certStatusResponse["certID"];
137 | }
138 |
139 | public function getEncodedResponseData(): string
140 | {
141 | return ASN1::encodeDER(
142 | $this->ocspBasicResponse["tbsResponseData"],
143 | OcspBasicResponseMap::MAP["children"]["tbsResponseData"]
144 | );
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/example/src/Auth.php:
--------------------------------------------------------------------------------
1 | config = $config;
44 | }
45 |
46 | public function trustedIntermediateCACertificates(): array
47 | {
48 | $directory = __DIR__ . "/../certificates/";
49 | $certificates = glob($directory . "*.der.crt");
50 | return CertificateLoader::loadCertificatesFromResources(...$certificates);
51 | }
52 |
53 | public function generator(): ChallengeNonceGenerator
54 | {
55 | return (new ChallengeNonceGeneratorBuilder())
56 | ->withNonceTtl(300)
57 | ->build();
58 | }
59 |
60 | public function tokenValidator(): AuthTokenValidator
61 | {
62 | $logger = new Logger();
63 |
64 | return (new AuthTokenValidatorBuilder($logger))
65 | // Change the URL when you run the example in your own machine.
66 | ->withSiteOrigin(new Uri($this->config->get('origin_url')))
67 | ->withTrustedCertificateAuthorities(...self::trustedIntermediateCACertificates())
68 | ->build();
69 | }
70 |
71 | /**
72 | * Get challenge nonce
73 | *
74 | * @return string
75 | */
76 | public function getNonce()
77 | {
78 | try {
79 | header("Content-Type: application/json; charset=utf-8");
80 | $challengeNonce = $this->generator()->generateAndStoreNonce();
81 | $responseArr = ["nonce" => $challengeNonce->getBase64EncodedNonce()];
82 | echo json_encode($responseArr);
83 | } catch (Exception $e) {
84 | header("HTTP/1.0 500 Internal Server Error");
85 | echo "Nonce generation failed";
86 | }
87 | }
88 |
89 | private function getPrincipalNameFromCertificate(X509 $userCertificate): string
90 | {
91 | $givenName = CertificateData::getSubjectGivenName($userCertificate);
92 | $surname = CertificateData::getSubjectSurname($userCertificate);
93 | if ($givenName && $surname) {
94 | return $givenName . " " . $surname;
95 | } else {
96 | return CertificateData::getSubjectCN($userCertificate);
97 | }
98 | }
99 |
100 | /**
101 | * Authenticate
102 | *
103 | * @return string
104 | */
105 | public function validate()
106 | {
107 | // Header names must be treated as case-insensitive (according to RFC2616) so we convert them to lowercase
108 | $headers = array_change_key_case(getallheaders(), CASE_LOWER);
109 |
110 | if (!isset($headers["x-csrf-token"]) || ($headers["x-csrf-token"] != $_SESSION["csrf-token"])) {
111 | header("HTTP/1.0 405 Method Not Allowed");
112 | echo "CSRF token missing, unable to process your request";
113 | return;
114 | }
115 |
116 | $authToken = file_get_contents("php://input");
117 |
118 | try {
119 |
120 | /* Get and remove nonce from store */
121 | $challengeNonce = (new ChallengeNonceStore())->getAndRemove();
122 |
123 | try {
124 |
125 | // Validate token
126 | $cert = $this->tokenValidator()->validate(new WebEidAuthToken($authToken), $challengeNonce->getBase64EncodedNonce());
127 |
128 | session_regenerate_id();
129 |
130 | $subjectName = $this->getPrincipalNameFromCertificate($cert);
131 | $result = [
132 | "sub" => $subjectName
133 | ];
134 |
135 | $_SESSION["auth-user"] = $subjectName;
136 |
137 | echo json_encode($result);
138 | } catch (Exception $e) {
139 | header("HTTP/1.0 400 Bad Request");
140 | echo "Validation failed";
141 | }
142 | } catch (ChallengeNonceExpiredException $e) {
143 | header("HTTP/1.0 400 Bad Request");
144 | echo "Challenge nonce not found or expired";
145 | }
146 | }
147 |
148 | /**
149 | * Logout
150 | *
151 | */
152 | public function logout()
153 | {
154 | unset($_SESSION["auth-user"]);
155 | session_regenerate_id();
156 | // Redirect to login
157 | header("Location: /");
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/src/ocsp/OcspResponse.php:
--------------------------------------------------------------------------------
1 | ocspResponse = ASN1::asn1map($decoded[0], OcspResponseMap::MAP, [
46 | "response" => function ($encoded) {
47 | return ASN1::asn1map(
48 | self::getDecoded($encoded)[0],
49 | OcspBasicResponseMap::MAP
50 | );
51 | },
52 | ]);
53 | }
54 |
55 | public function getResponse(): array
56 | {
57 | return $this->ocspResponse;
58 | }
59 |
60 | public function getBasicResponse(): OcspBasicResponse
61 | {
62 | if (
63 | Ocsp::ID_PKIX_OCSP_BASIC_STRING !=
64 | $this->ocspResponse["responseBytes"]["responseType"]
65 | ) {
66 | throw new UnexpectedValueException(
67 | 'responseType is not "id-pkix-ocsp-basic" but is ' .
68 | $this->ocspResponse["responseBytes"]["responseType"]
69 | );
70 | }
71 |
72 | if (!$this->ocspResponse["responseBytes"]["response"]) {
73 | throw new UnexpectedValueException(
74 | "Could not decode OcspResponse->responseBytes->response"
75 | );
76 | }
77 |
78 | return new OcspBasicResponse(
79 | $this->ocspResponse["responseBytes"]["response"]
80 | );
81 | }
82 |
83 | public function getStatus(): string
84 | {
85 | return $this->ocspResponse["responseStatus"];
86 | }
87 |
88 | public function getRevokeReason(): string
89 | {
90 | return $this->revokeReason;
91 | }
92 |
93 | public function isRevoked()
94 | {
95 | $basicResponse = $this->getBasicResponse();
96 | $this->validateResponse($basicResponse);
97 |
98 | if (isset($basicResponse->getResponses()[0]["certStatus"]["good"])) {
99 | return false;
100 | }
101 | if (isset($basicResponse->getResponses()[0]["certStatus"]["revoked"])) {
102 | $revokedStatus = $basicResponse->getResponses()[0]["certStatus"][
103 | "revoked"
104 | ];
105 | // Check revoke reason
106 | if (isset($revokedStatus["revokedReason"])) {
107 | $this->revokeReason = $revokedStatus["revokedReason"];
108 | }
109 | return true;
110 | }
111 | return null;
112 | }
113 |
114 | public function validateSignature(): void
115 | {
116 | $basicResponse = $this->getBasicResponse();
117 | $this->validateResponse($basicResponse);
118 |
119 | $responderCert = $basicResponse->getCertificates()[0];
120 | // get public key from responder certificate in order to verify signature on response
121 | $publicKey = $responderCert
122 | ->getPublicKey()
123 | ->withHash($basicResponse->getSignatureAlgorithm());
124 | // verify response data
125 | $encodedTbsResponseData = $basicResponse->getEncodedResponseData();
126 | $signature = $basicResponse->getSignature();
127 |
128 | if (!$publicKey->verify($encodedTbsResponseData, $signature)) {
129 | throw new OcspVerifyFailedException(
130 | "OCSP response signature is not valid"
131 | );
132 | }
133 | }
134 |
135 | public function validateCertificateId(array $requestCertificateId): void
136 | {
137 | $basicResponse = $this->getBasicResponse();
138 | if ($requestCertificateId != $basicResponse->getCertID()) {
139 | throw new OcspVerifyFailedException(
140 | "OCSP responded with certificate ID that differs from the requested ID"
141 | );
142 | }
143 | }
144 |
145 | private function validateResponse(OcspBasicResponse $basicResponse): void
146 | {
147 | // Must be one response
148 | if (count($basicResponse->getResponses()) != 1) {
149 | throw new OcspVerifyFailedException(
150 | "OCSP response must contain one response, received " .
151 | count($basicResponse->getResponses()) .
152 | " responses instead"
153 | );
154 | }
155 |
156 | // At least on cert must exist in responder
157 | if (count($basicResponse->getCertificates()) < 1) {
158 | throw new OcspVerifyFailedException(
159 | "OCSP response must contain the responder certificate, but none was provided"
160 | );
161 | }
162 | }
163 |
164 | private static function getDecoded(string $encodedBER) {
165 | $decoded = ASN1::decodeBER($encodedBER);
166 | if (!is_array($decoded)) {
167 | throw new OcspResponseDecodeException();
168 | }
169 | return $decoded;
170 | }
171 | }
172 |
--------------------------------------------------------------------------------
/src/validator/ocsp/OcspResponseValidator.php:
--------------------------------------------------------------------------------
1 |
44 | * https://oidref.com/1.3.6.1.5.5.7.3.9.
45 | */
46 | private const OCSP_SIGNING = "id-kp-OCSPSigning";
47 | private const ERROR_PREFIX = "Certificate status update time check failed: ";
48 | public function __construct()
49 | {
50 | throw new BadFunctionCallException("Utility class");
51 | }
52 |
53 | public static function validateHasSigningExtension(X509 $certificate): void
54 | {
55 | if (!$certificate->getExtension("id-ce-extKeyUsage") || !in_array(self::OCSP_SIGNING, $certificate->getExtension("id-ce-extKeyUsage"))) {
56 | throw new OCSPCertificateException("Certificate " . $certificate->getSubjectDN(X509::DN_STRING) . " does not contain the key usage extension for OCSP response signing");
57 | }
58 | }
59 |
60 | public static function validateResponseSignature(OcspBasicResponse $basicResponse, X509 $responderCert): void
61 | {
62 | // get public key from responder certificate in order to verify signature on response
63 | $publicKey = $responderCert->getPublicKey()->withHash($basicResponse->getSignatureAlgorithm());
64 | // verify response data
65 | $encodedTbsResponseData = $basicResponse->getEncodedResponseData();
66 | $signature = $basicResponse->getSignature();
67 |
68 | if (!$publicKey->verify($encodedTbsResponseData, $signature)) {
69 | throw new UserCertificateOCSPCheckFailedException("OCSP response signature is invalid");
70 | }
71 | }
72 |
73 | public static function validateCertificateStatusUpdateTime(OcspBasicResponse $basicResponse, int $allowedOcspResponseTimeSkew, int $maxOcspResponseThisUpdateAge): void
74 | {
75 | // From RFC 2560, https://www.ietf.org/rfc/rfc2560.txt:
76 | // 4.2.2. Notes on OCSP Responses
77 | // 4.2.2.1. Time
78 | // Responses whose nextUpdate value is earlier than
79 | // the local system time value SHOULD be considered unreliable.
80 | // Responses whose thisUpdate time is later than the local system time
81 | // SHOULD be considered unreliable.
82 | // If nextUpdate is not set, the responder is indicating that newer
83 | // revocation information is available all the time.
84 | $now = DefaultClock::getInstance()->now();
85 | $earliestAcceptableTimeSkew = (clone $now)->sub(new DateInterval('PT' . $allowedOcspResponseTimeSkew . 'M'));
86 | $latestAcceptableTimeSkew = (clone $now)->add(new DateInterval('PT' . $allowedOcspResponseTimeSkew . 'M'));
87 | $minimumValidThisUpdateTime = (clone $now)->sub(new DateInterval('PT' . $maxOcspResponseThisUpdateAge . 'M'));
88 |
89 | $thisUpdate = $basicResponse->getThisUpdate();
90 | if ($thisUpdate > $latestAcceptableTimeSkew) {
91 | throw new UserCertificateOCSPCheckFailedException(self::ERROR_PREFIX .
92 | "thisUpdate '" . DateAndTime::toUtcString($thisUpdate) . "' is too far in the future, " .
93 | "latest allowed: '" . DateAndTime::toUtcString($latestAcceptableTimeSkew) . "'");
94 | }
95 |
96 | if ($thisUpdate < $minimumValidThisUpdateTime) {
97 | throw new UserCertificateOCSPCheckFailedException(self::ERROR_PREFIX .
98 | "thisUpdate '" . DateAndTime::toUtcString($thisUpdate) . "' is too old, " .
99 | "minimum time allowed: '" . DateAndTime::toUtcString($minimumValidThisUpdateTime) . "'");
100 | }
101 |
102 | $nextUpdate = $basicResponse->getNextUpdate();
103 | if (is_null($nextUpdate)) {
104 | return;
105 | }
106 |
107 | if ($nextUpdate < $earliestAcceptableTimeSkew) {
108 | throw new UserCertificateOCSPCheckFailedException(self::ERROR_PREFIX .
109 | "nextUpdate '" . DateAndTime::toUtcString($nextUpdate) . "' is in the past");
110 | }
111 |
112 | if ($nextUpdate < $thisUpdate) {
113 | throw new UserCertificateOCSPCheckFailedException(self::ERROR_PREFIX .
114 | "nextUpdate '" . DateAndTime::toUtcString($nextUpdate) . "' is before thisUpdate '" . DateAndTime::toUtcString($thisUpdate) . "'");
115 | }
116 | }
117 |
118 | public static function validateSubjectCertificateStatus(OcspResponse $response): void
119 | {
120 | if (is_null($response->isRevoked())) {
121 | throw new UserCertificateRevokedException("Unknown status");
122 | }
123 | if ($response->isRevoked() === false) {
124 | return;
125 | }
126 | if ($response->isRevoked() === true) {
127 | throw ($response->getRevokeReason() == "") ? new UserCertificateRevokedException() : new UserCertificateRevokedException("Revocation reason: " . $response->getRevokeReason());
128 | }
129 | throw new UserCertificateRevokedException("Status is neither good, revoked nor unknown");
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/src/util/AsnUtil.php:
--------------------------------------------------------------------------------
1 | 0x7f.
78 | //
79 | // b1 = length of contents.
80 | // b2 = length of r after being prefixed if necessary.
81 | // b3 = length of s after being prefixed if necessary.
82 |
83 | $asn1 = ''; // ASN.1 contents.
84 | $len = 0; // Length of ASN.1 contents.
85 | $c_len = intdiv(strlen($p1363), 2); // Length of each P1363 component.
86 |
87 | // Separate P1363 signature into its two equally sized components.
88 | foreach (str_split($p1363, $c_len) as $c) {
89 | // 0x02 prefix before each component.
90 | $asn1 .= "\x02";
91 |
92 | if (unpack('C', $c)[1] > 0x7f) {
93 | // Add 0x00 because first byte of component > 0x7f.
94 | // Length of component = ($c_len + 1).
95 | $asn1 .= pack('C', $c_len + 1) . "\x00";
96 | $len += 2 + ($c_len + 1);
97 | } else {
98 | $asn1 .= pack('C', $c_len);
99 | $len += 2 + $c_len;
100 | }
101 |
102 | // Append formatted component to ASN.1 contents.
103 | $asn1 .= $c;
104 | }
105 |
106 | // 0x30 b1, then contents.
107 | return "\x30" . pack('C', $len) . $asn1;
108 | }
109 |
110 | public static function loadOIDs(): void
111 | {
112 | ASN1::loadOIDs([
113 | "id-pkix-ocsp-nonce" => self::ID_PKIX_OCSP_NONCE,
114 | "id-sha1" => "1.3.14.3.2.26",
115 | 'id-sha256' => '2.16.840.1.101.3.4.2.1',
116 | 'id-sha384' => '2.16.840.1.101.3.4.2.2',
117 | 'id-sha512' => '2.16.840.1.101.3.4.2.3',
118 | 'id-sha224' => '2.16.840.1.101.3.4.2.4',
119 | 'id-sha512/224' => '2.16.840.1.101.3.4.2.5',
120 | 'id-sha512/256' => '2.16.840.1.101.3.4.2.6',
121 | 'id-mgf1' => '1.2.840.113549.1.1.8',
122 | "sha256WithRSAEncryption" => "1.2.840.113549.1.1.11",
123 | "qcStatements(3)" => "1.3.6.1.5.5.7.1.3",
124 | "street" => "2.5.4.9",
125 | "id-pkix-ocsp-basic" => "1.3.6.1.5.5.7.48.1.1",
126 | "id-pkix-ocsp" => "1.3.6.1.5.5.7.48.1",
127 | "secp384r1" => "1.3.132.0.34",
128 | "id-pkix-ocsp-archive-cutoff" => "1.3.6.1.5.5.7.48.1.6",
129 | "id-pkix-ocsp-nocheck" => "1.3.6.1.5.5.7.48.1.5",
130 | ]);
131 | }
132 |
133 | public static function extractKeyData(string $publicKey): string
134 | {
135 | $extractedBER = ASN1::extractBER($publicKey);
136 | $decodedBER = ASN1::decodeBER($extractedBER);
137 | $subjectPublicKey = ASN1::asn1map(
138 | $decodedBER[0],
139 | SubjectPublicKeyInfo::MAP
140 | )["subjectPublicKey"];
141 | // Remove first byte
142 | return pack("c*", ...array_slice(unpack("c*", $subjectPublicKey), 1));
143 | }
144 |
145 | public static function decodeNonceExtension(array $ocspExtensions): ?string
146 | {
147 | $nonceExtension = current(
148 | array_filter(
149 | $ocspExtensions,
150 | function ($extension) {
151 | return self::ID_PKIX_OCSP_NONCE == ASN1::getOID($extension["extnId"]);
152 | }
153 | )
154 | );
155 | if (!$nonceExtension || !isset($nonceExtension["extnValue"])) {
156 | return null;
157 | }
158 |
159 | $nonceValue = $nonceExtension["extnValue"];
160 |
161 | $decoded = ASN1::decodeBER($nonceValue);
162 | if (is_array($decoded)) {
163 | // The value was DER-encoded, it is required to be an octet string.
164 | $nonceString = ASN1::asn1map($decoded[0], ['type' => ASN1::TYPE_OCTET_STRING]);
165 | return is_string($nonceString) ? $nonceString : null;
166 | }
167 |
168 | // The value was not DER-encoded, return it as-is.
169 | return $nonceValue;
170 | }
171 | }
172 |
--------------------------------------------------------------------------------
/src/validator/AuthTokenValidationConfiguration.php:
--------------------------------------------------------------------------------
1 | disallowedSubjectCertificatePolicies = [
56 | SubjectCertificatePolicies::$ESTEID_SK_2015_MOBILE_ID_POLICY_V1,
57 | SubjectCertificatePolicies::$ESTEID_SK_2015_MOBILE_ID_POLICY_V2,
58 | SubjectCertificatePolicies::$ESTEID_SK_2015_MOBILE_ID_POLICY_V3,
59 | SubjectCertificatePolicies::$ESTEID_SK_2015_MOBILE_ID_POLICY
60 | ];
61 | $this->nonceDisabledOcspUrls = new UriCollection();
62 | }
63 |
64 | public function setSiteOrigin(Uri $siteOrigin): void
65 | {
66 | $this->siteOrigin = $siteOrigin;
67 | }
68 |
69 | public function getSiteOrigin(): ?Uri
70 | {
71 | return $this->siteOrigin;
72 | }
73 |
74 | public function &getTrustedCACertificates(): array
75 | {
76 | return $this->trustedCACertificates;
77 | }
78 |
79 | public function isUserCertificateRevocationCheckWithOcspEnabled(): bool
80 | {
81 | return $this->isUserCertificateRevocationCheckWithOcspEnabled;
82 | }
83 |
84 | public function setUserCertificateRevocationCheckWithOcspDisabled(): void
85 | {
86 | $this->isUserCertificateRevocationCheckWithOcspEnabled = false;
87 | }
88 |
89 | public function getOcspRequestTimeout(): int
90 | {
91 | return $this->ocspRequestTimeout;
92 | }
93 |
94 | public function setOcspRequestTimeout(int $ocspRequestTimeout): void
95 | {
96 | $this->ocspRequestTimeout = $ocspRequestTimeout;
97 | }
98 |
99 | public function getAllowedOcspResponseTimeSkew(): int
100 | {
101 | return $this->allowedOcspResponseTimeSkew;
102 | }
103 |
104 | public function setAllowedOcspResponseTimeSkew(int $allowedOcspResponseTimeSkew): void
105 | {
106 | $this->allowedOcspResponseTimeSkew = $allowedOcspResponseTimeSkew;
107 | }
108 |
109 | public function getMaxOcspResponseThisUpdateAge(): int
110 | {
111 | return $this->maxOcspResponseThisUpdateAge;
112 | }
113 |
114 | public function setMaxOcspResponseThisUpdateAge(int $maxOcspResponseThisUpdateAge): void
115 | {
116 | $this->maxOcspResponseThisUpdateAge = $maxOcspResponseThisUpdateAge;
117 | }
118 |
119 | public function getDesignatedOcspServiceConfiguration(): ?DesignatedOcspServiceConfiguration
120 | {
121 | return $this->designatedOcspServiceConfiguration;
122 | }
123 |
124 | public function setDesignatedOcspServiceConfiguration(DesignatedOcspServiceConfiguration $designatedOcspServiceConfiguration): void
125 | {
126 | $this->designatedOcspServiceConfiguration = $designatedOcspServiceConfiguration;
127 | }
128 |
129 | public function &getDisallowedSubjectCertificatePolicies(): array
130 | {
131 | return $this->disallowedSubjectCertificatePolicies;
132 | }
133 |
134 | public function getNonceDisabledOcspUrls(): UriCollection
135 | {
136 | return $this->nonceDisabledOcspUrls;
137 | }
138 |
139 | /**
140 | * Checks that the configuration parameters are valid.
141 | *
142 | * @throws IllegalArgumentException when any parameter is invalid
143 | */
144 | public function validate(): void
145 | {
146 | if (is_null($this->siteOrigin)) {
147 | throw new InvalidArgumentException("Origin URI must not be null");
148 | }
149 |
150 | self::validateIsOriginURL($this->siteOrigin);
151 |
152 | if (count($this->trustedCACertificates) == 0) {
153 | throw new InvalidArgumentException("At least one trusted certificate authority must be provided");
154 | }
155 |
156 | DateAndTime::requirePositiveDuration($this->ocspRequestTimeout, "OCSP request timeout");
157 | DateAndTime::requirePositiveDuration($this->allowedOcspResponseTimeSkew, "Allowed OCSP response time-skew");
158 | DateAndTime::requirePositiveDuration($this->maxOcspResponseThisUpdateAge, "Max OCSP response thisUpdate age");
159 | }
160 |
161 | /**
162 | * Validates that the given URI is an origin URL as defined in MDN,
163 | * in the form of {@code