From 6632604528a08e9a1338b91be98873ee2ea5a6dd Mon Sep 17 00:00:00 2001 From: jakani24 Date: Thu, 4 Apr 2024 20:06:52 +0200 Subject: [PATCH] adding webauthn test code --- .../test/Attestation/AttestationObject.php | 179 +++++ .../test/Attestation/AuthenticatorData.php | 481 +++++++++++++ .../test/Attestation/Format/AndroidKey.php | 96 +++ .../Attestation/Format/AndroidSafetyNet.php | 152 ++++ .../test/Attestation/Format/Apple.php | 139 ++++ .../test/Attestation/Format/FormatBase.php | 193 +++++ .../test/Attestation/Format/None.php | 41 ++ .../test/Attestation/Format/Packed.php | 139 ++++ .../test/Attestation/Format/Tpm.php | 180 +++++ .../test/Attestation/Format/U2f.php | 93 +++ .../cyberhex-code/test/Binary/ByteBuffer.php | 300 ++++++++ .../cyberhex-code/test/CBOR/CborDecoder.php | 220 ++++++ src/server/cyberhex-code/test/WebAuthn.php | 677 ++++++++++++++++++ .../cyberhex-code/test/WebAuthnException.php | 28 + src/server/cyberhex-code/test/server.php | 334 +++++++++ src/server/cyberhex-code/test/user.html | 542 ++++++++++++++ 16 files changed, 3794 insertions(+) create mode 100644 src/server/cyberhex-code/test/Attestation/AttestationObject.php create mode 100644 src/server/cyberhex-code/test/Attestation/AuthenticatorData.php create mode 100644 src/server/cyberhex-code/test/Attestation/Format/AndroidKey.php create mode 100644 src/server/cyberhex-code/test/Attestation/Format/AndroidSafetyNet.php create mode 100644 src/server/cyberhex-code/test/Attestation/Format/Apple.php create mode 100644 src/server/cyberhex-code/test/Attestation/Format/FormatBase.php create mode 100644 src/server/cyberhex-code/test/Attestation/Format/None.php create mode 100644 src/server/cyberhex-code/test/Attestation/Format/Packed.php create mode 100644 src/server/cyberhex-code/test/Attestation/Format/Tpm.php create mode 100644 src/server/cyberhex-code/test/Attestation/Format/U2f.php create mode 100644 src/server/cyberhex-code/test/Binary/ByteBuffer.php create mode 100644 src/server/cyberhex-code/test/CBOR/CborDecoder.php create mode 100644 src/server/cyberhex-code/test/WebAuthn.php create mode 100644 src/server/cyberhex-code/test/WebAuthnException.php create mode 100644 src/server/cyberhex-code/test/server.php create mode 100644 src/server/cyberhex-code/test/user.html diff --git a/src/server/cyberhex-code/test/Attestation/AttestationObject.php b/src/server/cyberhex-code/test/Attestation/AttestationObject.php new file mode 100644 index 0000000..65151ea --- /dev/null +++ b/src/server/cyberhex-code/test/Attestation/AttestationObject.php @@ -0,0 +1,179 @@ +_authenticatorData = new AuthenticatorData($enc['authData']->getBinaryString()); + $this->_attestationFormatName = $enc['fmt']; + + // Format ok? + if (!in_array($this->_attestationFormatName, $allowedFormats)) { + throw new WebAuthnException('invalid atttestation format: ' . $this->_attestationFormatName, WebAuthnException::INVALID_DATA); + } + + + switch ($this->_attestationFormatName) { + case 'android-key': $this->_attestationFormat = new Format\AndroidKey($enc, $this->_authenticatorData); break; + case 'android-safetynet': $this->_attestationFormat = new Format\AndroidSafetyNet($enc, $this->_authenticatorData); break; + case 'apple': $this->_attestationFormat = new Format\Apple($enc, $this->_authenticatorData); break; + case 'fido-u2f': $this->_attestationFormat = new Format\U2f($enc, $this->_authenticatorData); break; + case 'none': $this->_attestationFormat = new Format\None($enc, $this->_authenticatorData); break; + case 'packed': $this->_attestationFormat = new Format\Packed($enc, $this->_authenticatorData); break; + case 'tpm': $this->_attestationFormat = new Format\Tpm($enc, $this->_authenticatorData); break; + default: throw new WebAuthnException('invalid attestation format: ' . $enc['fmt'], WebAuthnException::INVALID_DATA); + } + } + + /** + * returns the attestation format name + * @return string + */ + public function getAttestationFormatName() { + return $this->_attestationFormatName; + } + + /** + * returns the attestation format class + * @return Format\FormatBase + */ + public function getAttestationFormat() { + return $this->_attestationFormat; + } + + /** + * returns the attestation public key in PEM format + * @return AuthenticatorData + */ + public function getAuthenticatorData() { + return $this->_authenticatorData; + } + + /** + * returns the certificate chain as PEM + * @return string|null + */ + public function getCertificateChain() { + return $this->_attestationFormat->getCertificateChain(); + } + + /** + * return the certificate issuer as string + * @return string + */ + public function getCertificateIssuer() { + $pem = $this->getCertificatePem(); + $issuer = ''; + if ($pem) { + $certInfo = \openssl_x509_parse($pem); + if (\is_array($certInfo) && \array_key_exists('issuer', $certInfo) && \is_array($certInfo['issuer'])) { + + $cn = $certInfo['issuer']['CN'] ?? ''; + $o = $certInfo['issuer']['O'] ?? ''; + $ou = $certInfo['issuer']['OU'] ?? ''; + + if ($cn) { + $issuer .= $cn; + } + if ($issuer && ($o || $ou)) { + $issuer .= ' (' . trim($o . ' ' . $ou) . ')'; + } else { + $issuer .= trim($o . ' ' . $ou); + } + } + } + + return $issuer; + } + + /** + * return the certificate subject as string + * @return string + */ + public function getCertificateSubject() { + $pem = $this->getCertificatePem(); + $subject = ''; + if ($pem) { + $certInfo = \openssl_x509_parse($pem); + if (\is_array($certInfo) && \array_key_exists('subject', $certInfo) && \is_array($certInfo['subject'])) { + + $cn = $certInfo['subject']['CN'] ?? ''; + $o = $certInfo['subject']['O'] ?? ''; + $ou = $certInfo['subject']['OU'] ?? ''; + + if ($cn) { + $subject .= $cn; + } + if ($subject && ($o || $ou)) { + $subject .= ' (' . trim($o . ' ' . $ou) . ')'; + } else { + $subject .= trim($o . ' ' . $ou); + } + } + } + + return $subject; + } + + /** + * returns the key certificate in PEM format + * @return string + */ + public function getCertificatePem() { + return $this->_attestationFormat->getCertificatePem(); + } + + /** + * checks validity of the signature + * @param string $clientDataHash + * @return bool + * @throws WebAuthnException + */ + public function validateAttestation($clientDataHash) { + return $this->_attestationFormat->validateAttestation($clientDataHash); + } + + /** + * validates the certificate against root certificates + * @param array $rootCas + * @return boolean + * @throws WebAuthnException + */ + public function validateRootCertificate($rootCas) { + return $this->_attestationFormat->validateRootCertificate($rootCas); + } + + /** + * checks if the RpId-Hash is valid + * @param string$rpIdHash + * @return bool + */ + public function validateRpIdHash($rpIdHash) { + return $rpIdHash === $this->_authenticatorData->getRpIdHash(); + } +} diff --git a/src/server/cyberhex-code/test/Attestation/AuthenticatorData.php b/src/server/cyberhex-code/test/Attestation/AuthenticatorData.php new file mode 100644 index 0000000..0a9eec8 --- /dev/null +++ b/src/server/cyberhex-code/test/Attestation/AuthenticatorData.php @@ -0,0 +1,481 @@ +_binary = $binary; + + // Read infos from binary + // https://www.w3.org/TR/webauthn/#sec-authenticator-data + + // RP ID + $this->_rpIdHash = \substr($binary, 0, 32); + + // flags (1 byte) + $flags = \unpack('Cflags', \substr($binary, 32, 1))['flags']; + $this->_flags = $this->_readFlags($flags); + + // signature counter: 32-bit unsigned big-endian integer. + $this->_signCount = \unpack('Nsigncount', \substr($binary, 33, 4))['signcount']; + + $offset = 37; + // https://www.w3.org/TR/webauthn/#sec-attested-credential-data + if ($this->_flags->attestedDataIncluded) { + $this->_attestedCredentialData = $this->_readAttestData($binary, $offset); + } + + if ($this->_flags->extensionDataIncluded) { + $this->_readExtensionData(\substr($binary, $offset)); + } + } + + /** + * Authenticator Attestation Globally Unique Identifier, a unique number + * that identifies the model of the authenticator (not the specific instance + * of the authenticator) + * The aaguid may be 0 if the user is using a old u2f device and/or if + * the browser is using the fido-u2f format. + * @return string + * @throws WebAuthnException + */ + public function getAAGUID() { + if (!($this->_attestedCredentialData instanceof \stdClass)) { + throw new WebAuthnException('credential data not included in authenticator data', WebAuthnException::INVALID_DATA); + } + return $this->_attestedCredentialData->aaguid; + } + + /** + * returns the authenticatorData as binary + * @return string + */ + public function getBinary() { + return $this->_binary; + } + + /** + * returns the credentialId + * @return string + * @throws WebAuthnException + */ + public function getCredentialId() { + if (!($this->_attestedCredentialData instanceof \stdClass)) { + throw new WebAuthnException('credential id not included in authenticator data', WebAuthnException::INVALID_DATA); + } + return $this->_attestedCredentialData->credentialId; + } + + /** + * returns the public key in PEM format + * @return string + */ + public function getPublicKeyPem() { + if (!($this->_attestedCredentialData instanceof \stdClass) || !isset($this->_attestedCredentialData->credentialPublicKey)) { + throw new WebAuthnException('credential data not included in authenticator data', WebAuthnException::INVALID_DATA); + } + + $der = null; + switch ($this->_attestedCredentialData->credentialPublicKey->kty ?? null) { + case self::$_EC2_TYPE: $der = $this->_getEc2Der(); break; + case self::$_RSA_TYPE: $der = $this->_getRsaDer(); break; + case self::$_OKP_TYPE: $der = $this->_getOkpDer(); break; + default: throw new WebAuthnException('invalid key type', WebAuthnException::INVALID_DATA); + } + + $pem = '-----BEGIN PUBLIC KEY-----' . "\n"; + $pem .= \chunk_split(\base64_encode($der), 64, "\n"); + $pem .= '-----END PUBLIC KEY-----' . "\n"; + return $pem; + } + + /** + * returns the public key in U2F format + * @return string + * @throws WebAuthnException + */ + public function getPublicKeyU2F() { + if (!($this->_attestedCredentialData instanceof \stdClass) || !isset($this->_attestedCredentialData->credentialPublicKey)) { + throw new WebAuthnException('credential data not included in authenticator data', WebAuthnException::INVALID_DATA); + } + if (($this->_attestedCredentialData->credentialPublicKey->kty ?? null) !== self::$_EC2_TYPE) { + throw new WebAuthnException('signature algorithm not ES256', WebAuthnException::INVALID_PUBLIC_KEY); + } + return "\x04" . // ECC uncompressed + $this->_attestedCredentialData->credentialPublicKey->x . + $this->_attestedCredentialData->credentialPublicKey->y; + } + + /** + * returns the SHA256 hash of the relying party id (=hostname) + * @return string + */ + public function getRpIdHash() { + return $this->_rpIdHash; + } + + /** + * returns the sign counter + * @return int + */ + public function getSignCount() { + return $this->_signCount; + } + + /** + * returns true if the user is present + * @return boolean + */ + public function getUserPresent() { + return $this->_flags->userPresent; + } + + /** + * returns true if the user is verified + * @return boolean + */ + public function getUserVerified() { + return $this->_flags->userVerified; + } + + // ----------------------------------------------- + // PRIVATE + // ----------------------------------------------- + + /** + * Returns DER encoded EC2 key + * @return string + */ + private function _getEc2Der() { + return $this->_der_sequence( + $this->_der_sequence( + $this->_der_oid("\x2A\x86\x48\xCE\x3D\x02\x01") . // OID 1.2.840.10045.2.1 ecPublicKey + $this->_der_oid("\x2A\x86\x48\xCE\x3D\x03\x01\x07") // 1.2.840.10045.3.1.7 prime256v1 + ) . + $this->_der_bitString($this->getPublicKeyU2F()) + ); + } + + /** + * Returns DER encoded EdDSA key + * @return string + */ + private function _getOkpDer() { + return $this->_der_sequence( + $this->_der_sequence( + $this->_der_oid("\x2B\x65\x70") // OID 1.3.101.112 curveEd25519 (EdDSA 25519 signature algorithm) + ) . + $this->_der_bitString($this->_attestedCredentialData->credentialPublicKey->x) + ); + } + + /** + * Returns DER encoded RSA key + * @return string + */ + private function _getRsaDer() { + return $this->_der_sequence( + $this->_der_sequence( + $this->_der_oid("\x2A\x86\x48\x86\xF7\x0D\x01\x01\x01") . // OID 1.2.840.113549.1.1.1 rsaEncryption + $this->_der_nullValue() + ) . + $this->_der_bitString( + $this->_der_sequence( + $this->_der_unsignedInteger($this->_attestedCredentialData->credentialPublicKey->n) . + $this->_der_unsignedInteger($this->_attestedCredentialData->credentialPublicKey->e) + ) + ) + ); + } + + /** + * reads the flags from flag byte + * @param string $binFlag + * @return \stdClass + */ + private function _readFlags($binFlag) { + $flags = new \stdClass(); + + $flags->bit_0 = !!($binFlag & 1); + $flags->bit_1 = !!($binFlag & 2); + $flags->bit_2 = !!($binFlag & 4); + $flags->bit_3 = !!($binFlag & 8); + $flags->bit_4 = !!($binFlag & 16); + $flags->bit_5 = !!($binFlag & 32); + $flags->bit_6 = !!($binFlag & 64); + $flags->bit_7 = !!($binFlag & 128); + + // named flags + $flags->userPresent = $flags->bit_0; + $flags->userVerified = $flags->bit_2; + $flags->attestedDataIncluded = $flags->bit_6; + $flags->extensionDataIncluded = $flags->bit_7; + return $flags; + } + + /** + * read attested data + * @param string $binary + * @param int $endOffset + * @return \stdClass + * @throws WebAuthnException + */ + private function _readAttestData($binary, &$endOffset) { + $attestedCData = new \stdClass(); + if (\strlen($binary) <= 55) { + throw new WebAuthnException('Attested data should be present but is missing', WebAuthnException::INVALID_DATA); + } + + // The AAGUID of the authenticator + $attestedCData->aaguid = \substr($binary, 37, 16); + + //Byte length L of Credential ID, 16-bit unsigned big-endian integer. + $length = \unpack('nlength', \substr($binary, 53, 2))['length']; + $attestedCData->credentialId = \substr($binary, 55, $length); + + // set end offset + $endOffset = 55 + $length; + + // extract public key + $attestedCData->credentialPublicKey = $this->_readCredentialPublicKey($binary, 55 + $length, $endOffset); + + return $attestedCData; + } + + /** + * reads COSE key-encoded elliptic curve public key in EC2 format + * @param string $binary + * @param int $endOffset + * @return \stdClass + * @throws WebAuthnException + */ + private function _readCredentialPublicKey($binary, $offset, &$endOffset) { + $enc = CborDecoder::decodeInPlace($binary, $offset, $endOffset); + + // COSE key-encoded elliptic curve public key in EC2 format + $credPKey = new \stdClass(); + $credPKey->kty = $enc[self::$_COSE_KTY]; + $credPKey->alg = $enc[self::$_COSE_ALG]; + + switch ($credPKey->alg) { + case self::$_EC2_ES256: $this->_readCredentialPublicKeyES256($credPKey, $enc); break; + case self::$_RSA_RS256: $this->_readCredentialPublicKeyRS256($credPKey, $enc); break; + case self::$_OKP_EDDSA: $this->_readCredentialPublicKeyEDDSA($credPKey, $enc); break; + } + + return $credPKey; + } + + /** + * extract EDDSA informations from cose + * @param \stdClass $credPKey + * @param \stdClass $enc + * @throws WebAuthnException + */ + private function _readCredentialPublicKeyEDDSA(&$credPKey, $enc) { + $credPKey->crv = $enc[self::$_COSE_CRV]; + $credPKey->x = $enc[self::$_COSE_X] instanceof ByteBuffer ? $enc[self::$_COSE_X]->getBinaryString() : null; + unset ($enc); + + // Validation + if ($credPKey->kty !== self::$_OKP_TYPE) { + throw new WebAuthnException('public key not in OKP format', WebAuthnException::INVALID_PUBLIC_KEY); + } + + if ($credPKey->alg !== self::$_OKP_EDDSA) { + throw new WebAuthnException('signature algorithm not EdDSA', WebAuthnException::INVALID_PUBLIC_KEY); + } + + if ($credPKey->crv !== self::$_OKP_ED25519) { + throw new WebAuthnException('curve not Ed25519', WebAuthnException::INVALID_PUBLIC_KEY); + } + + if (\strlen($credPKey->x) !== 32) { + throw new WebAuthnException('Invalid X-coordinate', WebAuthnException::INVALID_PUBLIC_KEY); + } + } + + /** + * extract ES256 informations from cose + * @param \stdClass $credPKey + * @param \stdClass $enc + * @throws WebAuthnException + */ + private function _readCredentialPublicKeyES256(&$credPKey, $enc) { + $credPKey->crv = $enc[self::$_COSE_CRV]; + $credPKey->x = $enc[self::$_COSE_X] instanceof ByteBuffer ? $enc[self::$_COSE_X]->getBinaryString() : null; + $credPKey->y = $enc[self::$_COSE_Y] instanceof ByteBuffer ? $enc[self::$_COSE_Y]->getBinaryString() : null; + unset ($enc); + + // Validation + if ($credPKey->kty !== self::$_EC2_TYPE) { + throw new WebAuthnException('public key not in EC2 format', WebAuthnException::INVALID_PUBLIC_KEY); + } + + if ($credPKey->alg !== self::$_EC2_ES256) { + throw new WebAuthnException('signature algorithm not ES256', WebAuthnException::INVALID_PUBLIC_KEY); + } + + if ($credPKey->crv !== self::$_EC2_P256) { + throw new WebAuthnException('curve not P-256', WebAuthnException::INVALID_PUBLIC_KEY); + } + + if (\strlen($credPKey->x) !== 32) { + throw new WebAuthnException('Invalid X-coordinate', WebAuthnException::INVALID_PUBLIC_KEY); + } + + if (\strlen($credPKey->y) !== 32) { + throw new WebAuthnException('Invalid Y-coordinate', WebAuthnException::INVALID_PUBLIC_KEY); + } + } + + /** + * extract RS256 informations from COSE + * @param \stdClass $credPKey + * @param \stdClass $enc + * @throws WebAuthnException + */ + private function _readCredentialPublicKeyRS256(&$credPKey, $enc) { + $credPKey->n = $enc[self::$_COSE_N] instanceof ByteBuffer ? $enc[self::$_COSE_N]->getBinaryString() : null; + $credPKey->e = $enc[self::$_COSE_E] instanceof ByteBuffer ? $enc[self::$_COSE_E]->getBinaryString() : null; + unset ($enc); + + // Validation + if ($credPKey->kty !== self::$_RSA_TYPE) { + throw new WebAuthnException('public key not in RSA format', WebAuthnException::INVALID_PUBLIC_KEY); + } + + if ($credPKey->alg !== self::$_RSA_RS256) { + throw new WebAuthnException('signature algorithm not ES256', WebAuthnException::INVALID_PUBLIC_KEY); + } + + if (\strlen($credPKey->n) !== 256) { + throw new WebAuthnException('Invalid RSA modulus', WebAuthnException::INVALID_PUBLIC_KEY); + } + + if (\strlen($credPKey->e) !== 3) { + throw new WebAuthnException('Invalid RSA public exponent', WebAuthnException::INVALID_PUBLIC_KEY); + } + + } + + /** + * reads cbor encoded extension data. + * @param string $binary + * @return array + * @throws WebAuthnException + */ + private function _readExtensionData($binary) { + $ext = CborDecoder::decode($binary); + if (!\is_array($ext)) { + throw new WebAuthnException('invalid extension data', WebAuthnException::INVALID_DATA); + } + + return $ext; + } + + + // --------------- + // DER functions + // --------------- + + private function _der_length($len) { + if ($len < 128) { + return \chr($len); + } + $lenBytes = ''; + while ($len > 0) { + $lenBytes = \chr($len % 256) . $lenBytes; + $len = \intdiv($len, 256); + } + return \chr(0x80 | \strlen($lenBytes)) . $lenBytes; + } + + private function _der_sequence($contents) { + return "\x30" . $this->_der_length(\strlen($contents)) . $contents; + } + + private function _der_oid($encoded) { + return "\x06" . $this->_der_length(\strlen($encoded)) . $encoded; + } + + private function _der_bitString($bytes) { + return "\x03" . $this->_der_length(\strlen($bytes) + 1) . "\x00" . $bytes; + } + + private function _der_nullValue() { + return "\x05\x00"; + } + + private function _der_unsignedInteger($bytes) { + $len = \strlen($bytes); + + // Remove leading zero bytes + for ($i = 0; $i < ($len - 1); $i++) { + if (\ord($bytes[$i]) !== 0) { + break; + } + } + if ($i !== 0) { + $bytes = \substr($bytes, $i); + } + + // If most significant bit is set, prefix with another zero to prevent it being seen as negative number + if ((\ord($bytes[0]) & 0x80) !== 0) { + $bytes = "\x00" . $bytes; + } + + return "\x02" . $this->_der_length(\strlen($bytes)) . $bytes; + } +} diff --git a/src/server/cyberhex-code/test/Attestation/Format/AndroidKey.php b/src/server/cyberhex-code/test/Attestation/Format/AndroidKey.php new file mode 100644 index 0000000..4581272 --- /dev/null +++ b/src/server/cyberhex-code/test/Attestation/Format/AndroidKey.php @@ -0,0 +1,96 @@ +_attestationObject['attStmt']; + + if (!\array_key_exists('alg', $attStmt) || $this->_getCoseAlgorithm($attStmt['alg']) === null) { + throw new WebAuthnException('unsupported alg: ' . $attStmt['alg'], WebAuthnException::INVALID_DATA); + } + + if (!\array_key_exists('sig', $attStmt) || !\is_object($attStmt['sig']) || !($attStmt['sig'] instanceof ByteBuffer)) { + throw new WebAuthnException('no signature found', WebAuthnException::INVALID_DATA); + } + + if (!\array_key_exists('x5c', $attStmt) || !\is_array($attStmt['x5c']) || \count($attStmt['x5c']) < 1) { + throw new WebAuthnException('invalid x5c certificate', WebAuthnException::INVALID_DATA); + } + + if (!\is_object($attStmt['x5c'][0]) || !($attStmt['x5c'][0] instanceof ByteBuffer)) { + throw new WebAuthnException('invalid x5c certificate', WebAuthnException::INVALID_DATA); + } + + $this->_alg = $attStmt['alg']; + $this->_signature = $attStmt['sig']->getBinaryString(); + $this->_x5c = $attStmt['x5c'][0]->getBinaryString(); + + if (count($attStmt['x5c']) > 1) { + for ($i=1; $i_x5c_chain[] = $attStmt['x5c'][$i]->getBinaryString(); + } + unset ($i); + } + } + + + /* + * returns the key certificate in PEM format + * @return string + */ + public function getCertificatePem() { + return $this->_createCertificatePem($this->_x5c); + } + + /** + * @param string $clientDataHash + */ + public function validateAttestation($clientDataHash) { + $publicKey = \openssl_pkey_get_public($this->getCertificatePem()); + + if ($publicKey === false) { + throw new WebAuthnException('invalid public key: ' . \openssl_error_string(), WebAuthnException::INVALID_PUBLIC_KEY); + } + + // Verify that sig is a valid signature over the concatenation of authenticatorData and clientDataHash + // using the attestation public key in attestnCert with the algorithm specified in alg. + $dataToVerify = $this->_authenticatorData->getBinary(); + $dataToVerify .= $clientDataHash; + + $coseAlgorithm = $this->_getCoseAlgorithm($this->_alg); + + // check certificate + return \openssl_verify($dataToVerify, $this->_signature, $publicKey, $coseAlgorithm->openssl) === 1; + } + + /** + * validates the certificate against root certificates + * @param array $rootCas + * @return boolean + * @throws WebAuthnException + */ + public function validateRootCertificate($rootCas) { + $chainC = $this->_createX5cChainFile(); + if ($chainC) { + $rootCas[] = $chainC; + } + + $v = \openssl_x509_checkpurpose($this->getCertificatePem(), -1, $rootCas); + if ($v === -1) { + throw new WebAuthnException('error on validating root certificate: ' . \openssl_error_string(), WebAuthnException::CERTIFICATE_NOT_TRUSTED); + } + return $v; + } +} + diff --git a/src/server/cyberhex-code/test/Attestation/Format/AndroidSafetyNet.php b/src/server/cyberhex-code/test/Attestation/Format/AndroidSafetyNet.php new file mode 100644 index 0000000..ba0db52 --- /dev/null +++ b/src/server/cyberhex-code/test/Attestation/Format/AndroidSafetyNet.php @@ -0,0 +1,152 @@ +_attestationObject['attStmt']; + + if (!\array_key_exists('ver', $attStmt) || !$attStmt['ver']) { + throw new WebAuthnException('invalid Android Safety Net Format', WebAuthnException::INVALID_DATA); + } + + if (!\array_key_exists('response', $attStmt) || !($attStmt['response'] instanceof ByteBuffer)) { + throw new WebAuthnException('invalid Android Safety Net Format', WebAuthnException::INVALID_DATA); + } + + $response = $attStmt['response']->getBinaryString(); + + // Response is a JWS [RFC7515] object in Compact Serialization. + // JWSs have three segments separated by two period ('.') characters + $parts = \explode('.', $response); + unset ($response); + if (\count($parts) !== 3) { + throw new WebAuthnException('invalid JWS data', WebAuthnException::INVALID_DATA); + } + + $header = $this->_base64url_decode($parts[0]); + $payload = $this->_base64url_decode($parts[1]); + $this->_signature = $this->_base64url_decode($parts[2]); + $this->_signedValue = $parts[0] . '.' . $parts[1]; + unset ($parts); + + $header = \json_decode($header); + $payload = \json_decode($payload); + + if (!($header instanceof \stdClass)) { + throw new WebAuthnException('invalid JWS header', WebAuthnException::INVALID_DATA); + } + if (!($payload instanceof \stdClass)) { + throw new WebAuthnException('invalid JWS payload', WebAuthnException::INVALID_DATA); + } + + if (!isset($header->x5c) || !is_array($header->x5c) || count($header->x5c) === 0) { + throw new WebAuthnException('No X.509 signature in JWS Header', WebAuthnException::INVALID_DATA); + } + + // algorithm + if (!\in_array($header->alg, array('RS256', 'ES256'))) { + throw new WebAuthnException('invalid JWS algorithm ' . $header->alg, WebAuthnException::INVALID_DATA); + } + + $this->_x5c = \base64_decode($header->x5c[0]); + $this->_payload = $payload; + + if (count($header->x5c) > 1) { + for ($i=1; $ix5c); $i++) { + $this->_x5c_chain[] = \base64_decode($header->x5c[$i]); + } + unset ($i); + } + } + + /** + * ctsProfileMatch: A stricter verdict of device integrity. + * If the value of ctsProfileMatch is true, then the profile of the device running your app matches + * the profile of a device that has passed Android compatibility testing and + * has been approved as a Google-certified Android device. + * @return bool + */ + public function ctsProfileMatch() { + return isset($this->_payload->ctsProfileMatch) ? !!$this->_payload->ctsProfileMatch : false; + } + + + /* + * returns the key certificate in PEM format + * @return string + */ + public function getCertificatePem() { + return $this->_createCertificatePem($this->_x5c); + } + + /** + * @param string $clientDataHash + */ + public function validateAttestation($clientDataHash) { + $publicKey = \openssl_pkey_get_public($this->getCertificatePem()); + + // Verify that the nonce in the response is identical to the Base64 encoding + // of the SHA-256 hash of the concatenation of authenticatorData and clientDataHash. + if (empty($this->_payload->nonce) || $this->_payload->nonce !== \base64_encode(\hash('SHA256', $this->_authenticatorData->getBinary() . $clientDataHash, true))) { + throw new WebAuthnException('invalid nonce in JWS payload', WebAuthnException::INVALID_DATA); + } + + // Verify that attestationCert is issued to the hostname "attest.android.com" + $certInfo = \openssl_x509_parse($this->getCertificatePem()); + if (!\is_array($certInfo) || ($certInfo['subject']['CN'] ?? '') !== 'attest.android.com') { + throw new WebAuthnException('invalid certificate CN in JWS (' . ($certInfo['subject']['CN'] ?? '-'). ')', WebAuthnException::INVALID_DATA); + } + + // Verify that the basicIntegrity attribute in the payload of response is true. + if (empty($this->_payload->basicIntegrity)) { + throw new WebAuthnException('invalid basicIntegrity in payload', WebAuthnException::INVALID_DATA); + } + + // check certificate + return \openssl_verify($this->_signedValue, $this->_signature, $publicKey, OPENSSL_ALGO_SHA256) === 1; + } + + + /** + * validates the certificate against root certificates + * @param array $rootCas + * @return boolean + * @throws WebAuthnException + */ + public function validateRootCertificate($rootCas) { + $chainC = $this->_createX5cChainFile(); + if ($chainC) { + $rootCas[] = $chainC; + } + + $v = \openssl_x509_checkpurpose($this->getCertificatePem(), -1, $rootCas); + if ($v === -1) { + throw new WebAuthnException('error on validating root certificate: ' . \openssl_error_string(), WebAuthnException::CERTIFICATE_NOT_TRUSTED); + } + return $v; + } + + + /** + * decode base64 url + * @param string $data + * @return string + */ + private function _base64url_decode($data) { + return \base64_decode(\strtr($data, '-_', '+/') . \str_repeat('=', 3 - (3 + \strlen($data)) % 4)); + } +} + diff --git a/src/server/cyberhex-code/test/Attestation/Format/Apple.php b/src/server/cyberhex-code/test/Attestation/Format/Apple.php new file mode 100644 index 0000000..e4f38e0 --- /dev/null +++ b/src/server/cyberhex-code/test/Attestation/Format/Apple.php @@ -0,0 +1,139 @@ +_attestationObject['attStmt']; + + + // certificate for validation + if (\array_key_exists('x5c', $attStmt) && \is_array($attStmt['x5c']) && \count($attStmt['x5c']) > 0) { + + // The attestation certificate attestnCert MUST be the first element in the array + $attestnCert = array_shift($attStmt['x5c']); + + if (!($attestnCert instanceof ByteBuffer)) { + throw new WebAuthnException('invalid x5c certificate', WebAuthnException::INVALID_DATA); + } + + $this->_x5c = $attestnCert->getBinaryString(); + + // certificate chain + foreach ($attStmt['x5c'] as $chain) { + if ($chain instanceof ByteBuffer) { + $this->_x5c_chain[] = $chain->getBinaryString(); + } + } + } else { + throw new WebAuthnException('invalid Apple attestation statement: missing x5c', WebAuthnException::INVALID_DATA); + } + } + + + /* + * returns the key certificate in PEM format + * @return string|null + */ + public function getCertificatePem() { + return $this->_createCertificatePem($this->_x5c); + } + + /** + * @param string $clientDataHash + */ + public function validateAttestation($clientDataHash) { + return $this->_validateOverX5c($clientDataHash); + } + + /** + * validates the certificate against root certificates + * @param array $rootCas + * @return boolean + * @throws WebAuthnException + */ + public function validateRootCertificate($rootCas) { + $chainC = $this->_createX5cChainFile(); + if ($chainC) { + $rootCas[] = $chainC; + } + + $v = \openssl_x509_checkpurpose($this->getCertificatePem(), -1, $rootCas); + if ($v === -1) { + throw new WebAuthnException('error on validating root certificate: ' . \openssl_error_string(), WebAuthnException::CERTIFICATE_NOT_TRUSTED); + } + return $v; + } + + /** + * validate if x5c is present + * @param string $clientDataHash + * @return bool + * @throws WebAuthnException + */ + protected function _validateOverX5c($clientDataHash) { + $publicKey = \openssl_pkey_get_public($this->getCertificatePem()); + + if ($publicKey === false) { + throw new WebAuthnException('invalid public key: ' . \openssl_error_string(), WebAuthnException::INVALID_PUBLIC_KEY); + } + + // Concatenate authenticatorData and clientDataHash to form nonceToHash. + $nonceToHash = $this->_authenticatorData->getBinary(); + $nonceToHash .= $clientDataHash; + + // Perform SHA-256 hash of nonceToHash to produce nonce + $nonce = hash('SHA256', $nonceToHash, true); + + $credCert = openssl_x509_read($this->getCertificatePem()); + if ($credCert === false) { + throw new WebAuthnException('invalid x5c certificate: ' . \openssl_error_string(), WebAuthnException::INVALID_DATA); + } + + $keyData = openssl_pkey_get_details(openssl_pkey_get_public($credCert)); + $key = is_array($keyData) && array_key_exists('key', $keyData) ? $keyData['key'] : null; + + + // Verify that nonce equals the value of the extension with OID ( 1.2.840.113635.100.8.2 ) in credCert. + $parsedCredCert = openssl_x509_parse($credCert); + $nonceExtension = $parsedCredCert['extensions']['1.2.840.113635.100.8.2'] ?? ''; + + // nonce padded by ASN.1 string: 30 24 A1 22 04 20 + // 30 — type tag indicating sequence + // 24 — 36 byte following + // A1 — Enumerated [1] + // 22 — 34 byte following + // 04 — type tag indicating octet string + // 20 — 32 byte following + + $asn1Padding = "\x30\x24\xA1\x22\x04\x20"; + if (substr($nonceExtension, 0, strlen($asn1Padding)) === $asn1Padding) { + $nonceExtension = substr($nonceExtension, strlen($asn1Padding)); + } + + if ($nonceExtension !== $nonce) { + throw new WebAuthnException('nonce doesn\'t equal the value of the extension with OID 1.2.840.113635.100.8.2', WebAuthnException::INVALID_DATA); + } + + // Verify that the credential public key equals the Subject Public Key of credCert. + $authKeyData = openssl_pkey_get_details(openssl_pkey_get_public($this->_authenticatorData->getPublicKeyPem())); + $authKey = is_array($authKeyData) && array_key_exists('key', $authKeyData) ? $authKeyData['key'] : null; + + if ($key === null || $key !== $authKey) { + throw new WebAuthnException('credential public key doesn\'t equal the Subject Public Key of credCert', WebAuthnException::INVALID_DATA); + } + + return true; + } + +} + diff --git a/src/server/cyberhex-code/test/Attestation/Format/FormatBase.php b/src/server/cyberhex-code/test/Attestation/Format/FormatBase.php new file mode 100644 index 0000000..765af85 --- /dev/null +++ b/src/server/cyberhex-code/test/Attestation/Format/FormatBase.php @@ -0,0 +1,193 @@ +_attestationObject = $AttestionObject; + $this->_authenticatorData = $authenticatorData; + } + + /** + * + */ + public function __destruct() { + // delete X.509 chain certificate file after use + if ($this->_x5c_tempFile && \is_file($this->_x5c_tempFile)) { + \unlink($this->_x5c_tempFile); + } + } + + /** + * returns the certificate chain in PEM format + * @return string|null + */ + public function getCertificateChain() { + if ($this->_x5c_tempFile && \is_file($this->_x5c_tempFile)) { + return \file_get_contents($this->_x5c_tempFile); + } + return null; + } + + /** + * returns the key X.509 certificate in PEM format + * @return string + */ + public function getCertificatePem() { + // need to be overwritten + return null; + } + + /** + * checks validity of the signature + * @param string $clientDataHash + * @return bool + * @throws WebAuthnException + */ + public function validateAttestation($clientDataHash) { + // need to be overwritten + return false; + } + + /** + * validates the certificate against root certificates + * @param array $rootCas + * @return boolean + * @throws WebAuthnException + */ + public function validateRootCertificate($rootCas) { + // need to be overwritten + return false; + } + + + /** + * create a PEM encoded certificate with X.509 binary data + * @param string $x5c + * @return string + */ + protected function _createCertificatePem($x5c) { + $pem = '-----BEGIN CERTIFICATE-----' . "\n"; + $pem .= \chunk_split(\base64_encode($x5c), 64, "\n"); + $pem .= '-----END CERTIFICATE-----' . "\n"; + return $pem; + } + + /** + * creates a PEM encoded chain file + * @return type + */ + protected function _createX5cChainFile() { + $content = ''; + if (\is_array($this->_x5c_chain) && \count($this->_x5c_chain) > 0) { + foreach ($this->_x5c_chain as $x5c) { + $certInfo = \openssl_x509_parse($this->_createCertificatePem($x5c)); + + // check if certificate is self signed + if (\is_array($certInfo) && \is_array($certInfo['issuer']) && \is_array($certInfo['subject'])) { + $selfSigned = false; + + $subjectKeyIdentifier = $certInfo['extensions']['subjectKeyIdentifier'] ?? null; + $authorityKeyIdentifier = $certInfo['extensions']['authorityKeyIdentifier'] ?? null; + + if ($authorityKeyIdentifier && substr($authorityKeyIdentifier, 0, 6) === 'keyid:') { + $authorityKeyIdentifier = substr($authorityKeyIdentifier, 6); + } + if ($subjectKeyIdentifier && substr($subjectKeyIdentifier, 0, 6) === 'keyid:') { + $subjectKeyIdentifier = substr($subjectKeyIdentifier, 6); + } + + if (($subjectKeyIdentifier && !$authorityKeyIdentifier) || ($authorityKeyIdentifier && $authorityKeyIdentifier === $subjectKeyIdentifier)) { + $selfSigned = true; + } + + if (!$selfSigned) { + $content .= "\n" . $this->_createCertificatePem($x5c) . "\n"; + } + } + } + } + + if ($content) { + $this->_x5c_tempFile = \tempnam(\sys_get_temp_dir(), 'x5c_'); + if (\file_put_contents($this->_x5c_tempFile, $content) !== false) { + return $this->_x5c_tempFile; + } + } + + return null; + } + + + /** + * returns the name and openssl key for provided cose number. + * @param int $coseNumber + * @return \stdClass|null + */ + protected function _getCoseAlgorithm($coseNumber) { + // https://www.iana.org/assignments/cose/cose.xhtml#algorithms + $coseAlgorithms = array( + array( + 'hash' => 'SHA1', + 'openssl' => OPENSSL_ALGO_SHA1, + 'cose' => array( + -65535 // RS1 + )), + + array( + 'hash' => 'SHA256', + 'openssl' => OPENSSL_ALGO_SHA256, + 'cose' => array( + -257, // RS256 + -37, // PS256 + -7, // ES256 + 5 // HMAC256 + )), + + array( + 'hash' => 'SHA384', + 'openssl' => OPENSSL_ALGO_SHA384, + 'cose' => array( + -258, // RS384 + -38, // PS384 + -35, // ES384 + 6 // HMAC384 + )), + + array( + 'hash' => 'SHA512', + 'openssl' => OPENSSL_ALGO_SHA512, + 'cose' => array( + -259, // RS512 + -39, // PS512 + -36, // ES512 + 7 // HMAC512 + )) + ); + + foreach ($coseAlgorithms as $coseAlgorithm) { + if (\in_array($coseNumber, $coseAlgorithm['cose'], true)) { + $return = new \stdClass(); + $return->hash = $coseAlgorithm['hash']; + $return->openssl = $coseAlgorithm['openssl']; + return $return; + } + } + + return null; + } +} diff --git a/src/server/cyberhex-code/test/Attestation/Format/None.php b/src/server/cyberhex-code/test/Attestation/Format/None.php new file mode 100644 index 0000000..ba95e40 --- /dev/null +++ b/src/server/cyberhex-code/test/Attestation/Format/None.php @@ -0,0 +1,41 @@ +_attestationObject['attStmt']; + + if (!\array_key_exists('alg', $attStmt) || $this->_getCoseAlgorithm($attStmt['alg']) === null) { + throw new WebAuthnException('unsupported alg: ' . $attStmt['alg'], WebAuthnException::INVALID_DATA); + } + + if (!\array_key_exists('sig', $attStmt) || !\is_object($attStmt['sig']) || !($attStmt['sig'] instanceof ByteBuffer)) { + throw new WebAuthnException('no signature found', WebAuthnException::INVALID_DATA); + } + + $this->_alg = $attStmt['alg']; + $this->_signature = $attStmt['sig']->getBinaryString(); + + // certificate for validation + if (\array_key_exists('x5c', $attStmt) && \is_array($attStmt['x5c']) && \count($attStmt['x5c']) > 0) { + + // The attestation certificate attestnCert MUST be the first element in the array + $attestnCert = array_shift($attStmt['x5c']); + + if (!($attestnCert instanceof ByteBuffer)) { + throw new WebAuthnException('invalid x5c certificate', WebAuthnException::INVALID_DATA); + } + + $this->_x5c = $attestnCert->getBinaryString(); + + // certificate chain + foreach ($attStmt['x5c'] as $chain) { + if ($chain instanceof ByteBuffer) { + $this->_x5c_chain[] = $chain->getBinaryString(); + } + } + } + } + + + /* + * returns the key certificate in PEM format + * @return string|null + */ + public function getCertificatePem() { + if (!$this->_x5c) { + return null; + } + return $this->_createCertificatePem($this->_x5c); + } + + /** + * @param string $clientDataHash + */ + public function validateAttestation($clientDataHash) { + if ($this->_x5c) { + return $this->_validateOverX5c($clientDataHash); + } else { + return $this->_validateSelfAttestation($clientDataHash); + } + } + + /** + * validates the certificate against root certificates + * @param array $rootCas + * @return boolean + * @throws WebAuthnException + */ + public function validateRootCertificate($rootCas) { + if (!$this->_x5c) { + return false; + } + + $chainC = $this->_createX5cChainFile(); + if ($chainC) { + $rootCas[] = $chainC; + } + + $v = \openssl_x509_checkpurpose($this->getCertificatePem(), -1, $rootCas); + if ($v === -1) { + throw new WebAuthnException('error on validating root certificate: ' . \openssl_error_string(), WebAuthnException::CERTIFICATE_NOT_TRUSTED); + } + return $v; + } + + /** + * validate if x5c is present + * @param string $clientDataHash + * @return bool + * @throws WebAuthnException + */ + protected function _validateOverX5c($clientDataHash) { + $publicKey = \openssl_pkey_get_public($this->getCertificatePem()); + + if ($publicKey === false) { + throw new WebAuthnException('invalid public key: ' . \openssl_error_string(), WebAuthnException::INVALID_PUBLIC_KEY); + } + + // Verify that sig is a valid signature over the concatenation of authenticatorData and clientDataHash + // using the attestation public key in attestnCert with the algorithm specified in alg. + $dataToVerify = $this->_authenticatorData->getBinary(); + $dataToVerify .= $clientDataHash; + + $coseAlgorithm = $this->_getCoseAlgorithm($this->_alg); + + // check certificate + return \openssl_verify($dataToVerify, $this->_signature, $publicKey, $coseAlgorithm->openssl) === 1; + } + + /** + * validate if self attestation is in use + * @param string $clientDataHash + * @return bool + */ + protected function _validateSelfAttestation($clientDataHash) { + // Verify that sig is a valid signature over the concatenation of authenticatorData and clientDataHash + // using the credential public key with alg. + $dataToVerify = $this->_authenticatorData->getBinary(); + $dataToVerify .= $clientDataHash; + + $publicKey = $this->_authenticatorData->getPublicKeyPem(); + + // check certificate + return \openssl_verify($dataToVerify, $this->_signature, $publicKey, OPENSSL_ALGO_SHA256) === 1; + } +} + diff --git a/src/server/cyberhex-code/test/Attestation/Format/Tpm.php b/src/server/cyberhex-code/test/Attestation/Format/Tpm.php new file mode 100644 index 0000000..338cd45 --- /dev/null +++ b/src/server/cyberhex-code/test/Attestation/Format/Tpm.php @@ -0,0 +1,180 @@ +_attestationObject['attStmt']; + + if (!\array_key_exists('ver', $attStmt) || $attStmt['ver'] !== '2.0') { + throw new WebAuthnException('invalid tpm version: ' . $attStmt['ver'], WebAuthnException::INVALID_DATA); + } + + if (!\array_key_exists('alg', $attStmt) || $this->_getCoseAlgorithm($attStmt['alg']) === null) { + throw new WebAuthnException('unsupported alg: ' . $attStmt['alg'], WebAuthnException::INVALID_DATA); + } + + if (!\array_key_exists('sig', $attStmt) || !\is_object($attStmt['sig']) || !($attStmt['sig'] instanceof ByteBuffer)) { + throw new WebAuthnException('signature not found', WebAuthnException::INVALID_DATA); + } + + if (!\array_key_exists('certInfo', $attStmt) || !\is_object($attStmt['certInfo']) || !($attStmt['certInfo'] instanceof ByteBuffer)) { + throw new WebAuthnException('certInfo not found', WebAuthnException::INVALID_DATA); + } + + if (!\array_key_exists('pubArea', $attStmt) || !\is_object($attStmt['pubArea']) || !($attStmt['pubArea'] instanceof ByteBuffer)) { + throw new WebAuthnException('pubArea not found', WebAuthnException::INVALID_DATA); + } + + $this->_alg = $attStmt['alg']; + $this->_signature = $attStmt['sig']->getBinaryString(); + $this->_certInfo = $attStmt['certInfo']; + $this->_pubArea = $attStmt['pubArea']; + + // certificate for validation + if (\array_key_exists('x5c', $attStmt) && \is_array($attStmt['x5c']) && \count($attStmt['x5c']) > 0) { + + // The attestation certificate attestnCert MUST be the first element in the array + $attestnCert = array_shift($attStmt['x5c']); + + if (!($attestnCert instanceof ByteBuffer)) { + throw new WebAuthnException('invalid x5c certificate', WebAuthnException::INVALID_DATA); + } + + $this->_x5c = $attestnCert->getBinaryString(); + + // certificate chain + foreach ($attStmt['x5c'] as $chain) { + if ($chain instanceof ByteBuffer) { + $this->_x5c_chain[] = $chain->getBinaryString(); + } + } + + } else { + throw new WebAuthnException('no x5c certificate found', WebAuthnException::INVALID_DATA); + } + } + + + /* + * returns the key certificate in PEM format + * @return string|null + */ + public function getCertificatePem() { + if (!$this->_x5c) { + return null; + } + return $this->_createCertificatePem($this->_x5c); + } + + /** + * @param string $clientDataHash + */ + public function validateAttestation($clientDataHash) { + return $this->_validateOverX5c($clientDataHash); + } + + /** + * validates the certificate against root certificates + * @param array $rootCas + * @return boolean + * @throws WebAuthnException + */ + public function validateRootCertificate($rootCas) { + if (!$this->_x5c) { + return false; + } + + $chainC = $this->_createX5cChainFile(); + if ($chainC) { + $rootCas[] = $chainC; + } + + $v = \openssl_x509_checkpurpose($this->getCertificatePem(), -1, $rootCas); + if ($v === -1) { + throw new WebAuthnException('error on validating root certificate: ' . \openssl_error_string(), WebAuthnException::CERTIFICATE_NOT_TRUSTED); + } + return $v; + } + + /** + * validate if x5c is present + * @param string $clientDataHash + * @return bool + * @throws WebAuthnException + */ + protected function _validateOverX5c($clientDataHash) { + $publicKey = \openssl_pkey_get_public($this->getCertificatePem()); + + if ($publicKey === false) { + throw new WebAuthnException('invalid public key: ' . \openssl_error_string(), WebAuthnException::INVALID_PUBLIC_KEY); + } + + // Concatenate authenticatorData and clientDataHash to form attToBeSigned. + $attToBeSigned = $this->_authenticatorData->getBinary(); + $attToBeSigned .= $clientDataHash; + + // Validate that certInfo is valid: + + // Verify that magic is set to TPM_GENERATED_VALUE. + if ($this->_certInfo->getBytes(0, 4) !== $this->_TPM_GENERATED_VALUE) { + throw new WebAuthnException('tpm magic not TPM_GENERATED_VALUE', WebAuthnException::INVALID_DATA); + } + + // Verify that type is set to TPM_ST_ATTEST_CERTIFY. + if ($this->_certInfo->getBytes(4, 2) !== $this->_TPM_ST_ATTEST_CERTIFY) { + throw new WebAuthnException('tpm type not TPM_ST_ATTEST_CERTIFY', WebAuthnException::INVALID_DATA); + } + + $offset = 6; + $qualifiedSigner = $this->_tpmReadLengthPrefixed($this->_certInfo, $offset); + $extraData = $this->_tpmReadLengthPrefixed($this->_certInfo, $offset); + $coseAlg = $this->_getCoseAlgorithm($this->_alg); + + // Verify that extraData is set to the hash of attToBeSigned using the hash algorithm employed in "alg". + if ($extraData->getBinaryString() !== \hash($coseAlg->hash, $attToBeSigned, true)) { + throw new WebAuthnException('certInfo:extraData not hash of attToBeSigned', WebAuthnException::INVALID_DATA); + } + + // Verify the sig is a valid signature over certInfo using the attestation + // public key in aikCert with the algorithm specified in alg. + return \openssl_verify($this->_certInfo->getBinaryString(), $this->_signature, $publicKey, $coseAlg->openssl) === 1; + } + + + /** + * returns next part of ByteBuffer + * @param ByteBuffer $buffer + * @param int $offset + * @return ByteBuffer + */ + protected function _tpmReadLengthPrefixed(ByteBuffer $buffer, &$offset) { + $len = $buffer->getUint16Val($offset); + $data = $buffer->getBytes($offset + 2, $len); + $offset += (2 + $len); + + return new ByteBuffer($data); + } + +} + diff --git a/src/server/cyberhex-code/test/Attestation/Format/U2f.php b/src/server/cyberhex-code/test/Attestation/Format/U2f.php new file mode 100644 index 0000000..2b51ba8 --- /dev/null +++ b/src/server/cyberhex-code/test/Attestation/Format/U2f.php @@ -0,0 +1,93 @@ +_attestationObject['attStmt']; + + if (\array_key_exists('alg', $attStmt) && $attStmt['alg'] !== $this->_alg) { + throw new WebAuthnException('u2f only accepts algorithm -7 ("ES256"), but got ' . $attStmt['alg'], WebAuthnException::INVALID_DATA); + } + + if (!\array_key_exists('sig', $attStmt) || !\is_object($attStmt['sig']) || !($attStmt['sig'] instanceof ByteBuffer)) { + throw new WebAuthnException('no signature found', WebAuthnException::INVALID_DATA); + } + + if (!\array_key_exists('x5c', $attStmt) || !\is_array($attStmt['x5c']) || \count($attStmt['x5c']) !== 1) { + throw new WebAuthnException('invalid x5c certificate', WebAuthnException::INVALID_DATA); + } + + if (!\is_object($attStmt['x5c'][0]) || !($attStmt['x5c'][0] instanceof ByteBuffer)) { + throw new WebAuthnException('invalid x5c certificate', WebAuthnException::INVALID_DATA); + } + + $this->_signature = $attStmt['sig']->getBinaryString(); + $this->_x5c = $attStmt['x5c'][0]->getBinaryString(); + } + + + /* + * returns the key certificate in PEM format + * @return string + */ + public function getCertificatePem() { + $pem = '-----BEGIN CERTIFICATE-----' . "\n"; + $pem .= \chunk_split(\base64_encode($this->_x5c), 64, "\n"); + $pem .= '-----END CERTIFICATE-----' . "\n"; + return $pem; + } + + /** + * @param string $clientDataHash + */ + public function validateAttestation($clientDataHash) { + $publicKey = \openssl_pkey_get_public($this->getCertificatePem()); + + if ($publicKey === false) { + throw new WebAuthnException('invalid public key: ' . \openssl_error_string(), WebAuthnException::INVALID_PUBLIC_KEY); + } + + // Let verificationData be the concatenation of (0x00 || rpIdHash || clientDataHash || credentialId || publicKeyU2F) + $dataToVerify = "\x00"; + $dataToVerify .= $this->_authenticatorData->getRpIdHash(); + $dataToVerify .= $clientDataHash; + $dataToVerify .= $this->_authenticatorData->getCredentialId(); + $dataToVerify .= $this->_authenticatorData->getPublicKeyU2F(); + + $coseAlgorithm = $this->_getCoseAlgorithm($this->_alg); + + // check certificate + return \openssl_verify($dataToVerify, $this->_signature, $publicKey, $coseAlgorithm->openssl) === 1; + } + + /** + * validates the certificate against root certificates + * @param array $rootCas + * @return boolean + * @throws WebAuthnException + */ + public function validateRootCertificate($rootCas) { + $chainC = $this->_createX5cChainFile(); + if ($chainC) { + $rootCas[] = $chainC; + } + + $v = \openssl_x509_checkpurpose($this->getCertificatePem(), -1, $rootCas); + if ($v === -1) { + throw new WebAuthnException('error on validating root certificate: ' . \openssl_error_string(), WebAuthnException::CERTIFICATE_NOT_TRUSTED); + } + return $v; + } +} diff --git a/src/server/cyberhex-code/test/Binary/ByteBuffer.php b/src/server/cyberhex-code/test/Binary/ByteBuffer.php new file mode 100644 index 0000000..861ed60 --- /dev/null +++ b/src/server/cyberhex-code/test/Binary/ByteBuffer.php @@ -0,0 +1,300 @@ +_data = (string)$binaryData; + $this->_length = \strlen($binaryData); + } + + + // ----------------------- + // PUBLIC STATIC + // ----------------------- + + /** + * create a ByteBuffer from a base64 url encoded string + * @param string $base64url + * @return ByteBuffer + */ + public static function fromBase64Url($base64url): ByteBuffer { + $bin = self::_base64url_decode($base64url); + if ($bin === false) { + throw new WebAuthnException('ByteBuffer: Invalid base64 url string', WebAuthnException::BYTEBUFFER); + } + return new ByteBuffer($bin); + } + + /** + * create a ByteBuffer from a base64 url encoded string + * @param string $hex + * @return ByteBuffer + */ + public static function fromHex($hex): ByteBuffer { + $bin = \hex2bin($hex); + if ($bin === false) { + throw new WebAuthnException('ByteBuffer: Invalid hex string', WebAuthnException::BYTEBUFFER); + } + return new ByteBuffer($bin); + } + + /** + * create a random ByteBuffer + * @param string $length + * @return ByteBuffer + */ + public static function randomBuffer($length): ByteBuffer { + if (\function_exists('random_bytes')) { // >PHP 7.0 + return new ByteBuffer(\random_bytes($length)); + + } else if (\function_exists('openssl_random_pseudo_bytes')) { + return new ByteBuffer(\openssl_random_pseudo_bytes($length)); + + } else { + throw new WebAuthnException('ByteBuffer: cannot generate random bytes', WebAuthnException::BYTEBUFFER); + } + } + + // ----------------------- + // PUBLIC + // ----------------------- + + public function getBytes($offset, $length): string { + if ($offset < 0 || $length < 0 || ($offset + $length > $this->_length)) { + throw new WebAuthnException('ByteBuffer: Invalid offset or length', WebAuthnException::BYTEBUFFER); + } + return \substr($this->_data, $offset, $length); + } + + public function getByteVal($offset): int { + if ($offset < 0 || $offset >= $this->_length) { + throw new WebAuthnException('ByteBuffer: Invalid offset', WebAuthnException::BYTEBUFFER); + } + return \ord(\substr($this->_data, $offset, 1)); + } + + public function getJson($jsonFlags=0) { + $data = \json_decode($this->getBinaryString(), null, 512, $jsonFlags); + if (\json_last_error() !== JSON_ERROR_NONE) { + throw new WebAuthnException(\json_last_error_msg(), WebAuthnException::BYTEBUFFER); + } + return $data; + } + + public function getLength(): int { + return $this->_length; + } + + public function getUint16Val($offset) { + if ($offset < 0 || ($offset + 2) > $this->_length) { + throw new WebAuthnException('ByteBuffer: Invalid offset', WebAuthnException::BYTEBUFFER); + } + return unpack('n', $this->_data, $offset)[1]; + } + + public function getUint32Val($offset) { + if ($offset < 0 || ($offset + 4) > $this->_length) { + throw new WebAuthnException('ByteBuffer: Invalid offset', WebAuthnException::BYTEBUFFER); + } + $val = unpack('N', $this->_data, $offset)[1]; + + // Signed integer overflow causes signed negative numbers + if ($val < 0) { + throw new WebAuthnException('ByteBuffer: Value out of integer range.', WebAuthnException::BYTEBUFFER); + } + return $val; + } + + public function getUint64Val($offset) { + if (PHP_INT_SIZE < 8) { + throw new WebAuthnException('ByteBuffer: 64-bit values not supported by this system', WebAuthnException::BYTEBUFFER); + } + if ($offset < 0 || ($offset + 8) > $this->_length) { + throw new WebAuthnException('ByteBuffer: Invalid offset', WebAuthnException::BYTEBUFFER); + } + $val = unpack('J', $this->_data, $offset)[1]; + + // Signed integer overflow causes signed negative numbers + if ($val < 0) { + throw new WebAuthnException('ByteBuffer: Value out of integer range.', WebAuthnException::BYTEBUFFER); + } + + return $val; + } + + public function getHalfFloatVal($offset) { + //FROM spec pseudo decode_half(unsigned char *halfp) + $half = $this->getUint16Val($offset); + + $exp = ($half >> 10) & 0x1f; + $mant = $half & 0x3ff; + + if ($exp === 0) { + $val = $mant * (2 ** -24); + } elseif ($exp !== 31) { + $val = ($mant + 1024) * (2 ** ($exp - 25)); + } else { + $val = ($mant === 0) ? INF : NAN; + } + + return ($half & 0x8000) ? -$val : $val; + } + + public function getFloatVal($offset) { + if ($offset < 0 || ($offset + 4) > $this->_length) { + throw new WebAuthnException('ByteBuffer: Invalid offset', WebAuthnException::BYTEBUFFER); + } + return unpack('G', $this->_data, $offset)[1]; + } + + public function getDoubleVal($offset) { + if ($offset < 0 || ($offset + 8) > $this->_length) { + throw new WebAuthnException('ByteBuffer: Invalid offset', WebAuthnException::BYTEBUFFER); + } + return unpack('E', $this->_data, $offset)[1]; + } + + /** + * @return string + */ + public function getBinaryString(): string { + return $this->_data; + } + + /** + * @param string|ByteBuffer $buffer + * @return bool + */ + public function equals($buffer): bool { + if (is_object($buffer) && $buffer instanceof ByteBuffer) { + return $buffer->getBinaryString() === $this->getBinaryString(); + + } else if (is_string($buffer)) { + return $buffer === $this->getBinaryString(); + } + + return false; + } + + /** + * @return string + */ + public function getHex(): string { + return \bin2hex($this->_data); + } + + /** + * @return bool + */ + public function isEmpty(): bool { + return $this->_length === 0; + } + + + /** + * jsonSerialize interface + * return binary data in RFC 1342-Like serialized string + * @return string + */ + public function jsonSerialize(): string { + if (ByteBuffer::$useBase64UrlEncoding) { + return self::_base64url_encode($this->_data); + + } else { + return '=?BINARY?B?' . \base64_encode($this->_data) . '?='; + } + } + + /** + * Serializable-Interface + * @return string + */ + public function serialize(): string { + return \serialize($this->_data); + } + + /** + * Serializable-Interface + * @param string $serialized + */ + public function unserialize($serialized) { + $this->_data = \unserialize($serialized); + $this->_length = \strlen($this->_data); + } + + /** + * (PHP 8 deprecates Serializable-Interface) + * @return array + */ + public function __serialize(): array { + return [ + 'data' => \serialize($this->_data) + ]; + } + + /** + * object to string + * @return string + */ + public function __toString(): string { + return $this->getHex(); + } + + /** + * (PHP 8 deprecates Serializable-Interface) + * @param array $data + * @return void + */ + public function __unserialize($data) { + if ($data && isset($data['data'])) { + $this->_data = \unserialize($data['data']); + $this->_length = \strlen($this->_data); + } + } + + // ----------------------- + // PROTECTED STATIC + // ----------------------- + + /** + * base64 url decoding + * @param string $data + * @return string + */ + protected static function _base64url_decode($data): string { + return \base64_decode(\strtr($data, '-_', '+/') . \str_repeat('=', 3 - (3 + \strlen($data)) % 4)); + } + + /** + * base64 url encoding + * @param string $data + * @return string + */ + protected static function _base64url_encode($data): string { + return \rtrim(\strtr(\base64_encode($data), '+/', '-_'), '='); + } +} diff --git a/src/server/cyberhex-code/test/CBOR/CborDecoder.php b/src/server/cyberhex-code/test/CBOR/CborDecoder.php new file mode 100644 index 0000000..e6b5427 --- /dev/null +++ b/src/server/cyberhex-code/test/CBOR/CborDecoder.php @@ -0,0 +1,220 @@ +getLength()) { + throw new WebAuthnException('Unused bytes after data item.', WebAuthnException::CBOR); + } + return $result; + } + + /** + * @param ByteBuffer|string $bufOrBin + * @param int $startOffset + * @param int|null $endOffset + * @return mixed + */ + public static function decodeInPlace($bufOrBin, $startOffset, &$endOffset = null) { + $buf = $bufOrBin instanceof ByteBuffer ? $bufOrBin : new ByteBuffer($bufOrBin); + + $offset = $startOffset; + $data = self::_parseItem($buf, $offset); + $endOffset = $offset; + return $data; + } + + // --------------------- + // protected + // --------------------- + + /** + * @param ByteBuffer $buf + * @param int $offset + * @return mixed + */ + protected static function _parseItem(ByteBuffer $buf, &$offset) { + $first = $buf->getByteVal($offset++); + $type = $first >> 5; + $val = $first & 0b11111; + + if ($type === self::CBOR_MAJOR_FLOAT_SIMPLE) { + return self::_parseFloatSimple($val, $buf, $offset); + } + + $val = self::_parseExtraLength($val, $buf, $offset); + + return self::_parseItemData($type, $val, $buf, $offset); + } + + protected static function _parseFloatSimple($val, ByteBuffer $buf, &$offset) { + switch ($val) { + case 24: + $val = $buf->getByteVal($offset); + $offset++; + return self::_parseSimple($val); + + case 25: + $floatValue = $buf->getHalfFloatVal($offset); + $offset += 2; + return $floatValue; + + case 26: + $floatValue = $buf->getFloatVal($offset); + $offset += 4; + return $floatValue; + + case 27: + $floatValue = $buf->getDoubleVal($offset); + $offset += 8; + return $floatValue; + + case 28: + case 29: + case 30: + throw new WebAuthnException('Reserved value used.', WebAuthnException::CBOR); + + case 31: + throw new WebAuthnException('Indefinite length is not supported.', WebAuthnException::CBOR); + } + + return self::_parseSimple($val); + } + + /** + * @param int $val + * @return mixed + * @throws WebAuthnException + */ + protected static function _parseSimple($val) { + if ($val === 20) { + return false; + } + if ($val === 21) { + return true; + } + if ($val === 22) { + return null; + } + throw new WebAuthnException(sprintf('Unsupported simple value %d.', $val), WebAuthnException::CBOR); + } + + protected static function _parseExtraLength($val, ByteBuffer $buf, &$offset) { + switch ($val) { + case 24: + $val = $buf->getByteVal($offset); + $offset++; + break; + + case 25: + $val = $buf->getUint16Val($offset); + $offset += 2; + break; + + case 26: + $val = $buf->getUint32Val($offset); + $offset += 4; + break; + + case 27: + $val = $buf->getUint64Val($offset); + $offset += 8; + break; + + case 28: + case 29: + case 30: + throw new WebAuthnException('Reserved value used.', WebAuthnException::CBOR); + + case 31: + throw new WebAuthnException('Indefinite length is not supported.', WebAuthnException::CBOR); + } + + return $val; + } + + protected static function _parseItemData($type, $val, ByteBuffer $buf, &$offset) { + switch ($type) { + case self::CBOR_MAJOR_UNSIGNED_INT: // uint + return $val; + + case self::CBOR_MAJOR_NEGATIVE_INT: + return -1 - $val; + + case self::CBOR_MAJOR_BYTE_STRING: + $data = $buf->getBytes($offset, $val); + $offset += $val; + return new ByteBuffer($data); // bytes + + case self::CBOR_MAJOR_TEXT_STRING: + $data = $buf->getBytes($offset, $val); + $offset += $val; + return $data; // UTF-8 + + case self::CBOR_MAJOR_ARRAY: + return self::_parseArray($buf, $offset, $val); + + case self::CBOR_MAJOR_MAP: + return self::_parseMap($buf, $offset, $val); + + case self::CBOR_MAJOR_TAG: + return self::_parseItem($buf, $offset); // 1 embedded data item + } + + // This should never be reached + throw new WebAuthnException(sprintf('Unknown major type %d.', $type), WebAuthnException::CBOR); + } + + protected static function _parseMap(ByteBuffer $buf, &$offset, $count) { + $map = array(); + + for ($i = 0; $i < $count; $i++) { + $mapKey = self::_parseItem($buf, $offset); + $mapVal = self::_parseItem($buf, $offset); + + if (!\is_int($mapKey) && !\is_string($mapKey)) { + throw new WebAuthnException('Can only use strings or integers as map keys', WebAuthnException::CBOR); + } + + $map[$mapKey] = $mapVal; // todo dup + } + return $map; + } + + protected static function _parseArray(ByteBuffer $buf, &$offset, $count) { + $arr = array(); + for ($i = 0; $i < $count; $i++) { + $arr[] = self::_parseItem($buf, $offset); + } + + return $arr; + } +} diff --git a/src/server/cyberhex-code/test/WebAuthn.php b/src/server/cyberhex-code/test/WebAuthn.php new file mode 100644 index 0000000..0da0aa6 --- /dev/null +++ b/src/server/cyberhex-code/test/WebAuthn.php @@ -0,0 +1,677 @@ +_rpName = $rpName; + $this->_rpId = $rpId; + $this->_rpIdHash = \hash('sha256', $rpId, true); + ByteBuffer::$useBase64UrlEncoding = !!$useBase64UrlEncoding; + $supportedFormats = array('android-key', 'android-safetynet', 'apple', 'fido-u2f', 'none', 'packed', 'tpm'); + + if (!\function_exists('\openssl_open')) { + throw new WebAuthnException('OpenSSL-Module not installed'); + } + + if (!\in_array('SHA256', \array_map('\strtoupper', \openssl_get_md_methods()))) { + throw new WebAuthnException('SHA256 not supported by this openssl installation.'); + } + + // default: all format + if (!is_array($allowedFormats)) { + $allowedFormats = $supportedFormats; + } + $this->_formats = $allowedFormats; + + // validate formats + $invalidFormats = \array_diff($this->_formats, $supportedFormats); + if (!$this->_formats || $invalidFormats) { + throw new WebAuthnException('invalid formats on construct: ' . implode(', ', $invalidFormats)); + } + } + + /** + * add a root certificate to verify new registrations + * @param string $path file path of / directory with root certificates + * @param array|null $certFileExtensions if adding a direction, all files with provided extension are added. default: pem, crt, cer, der + */ + public function addRootCertificates($path, $certFileExtensions=null) { + if (!\is_array($this->_caFiles)) { + $this->_caFiles = []; + } + if ($certFileExtensions === null) { + $certFileExtensions = array('pem', 'crt', 'cer', 'der'); + } + $path = \rtrim(\trim($path), '\\/'); + if (\is_dir($path)) { + foreach (\scandir($path) as $ca) { + if (\is_file($path . DIRECTORY_SEPARATOR . $ca) && \in_array(\strtolower(\pathinfo($ca, PATHINFO_EXTENSION)), $certFileExtensions)) { + $this->addRootCertificates($path . DIRECTORY_SEPARATOR . $ca); + } + } + } else if (\is_file($path) && !\in_array(\realpath($path), $this->_caFiles)) { + $this->_caFiles[] = \realpath($path); + } + } + + /** + * Returns the generated challenge to save for later validation + * @return ByteBuffer + */ + public function getChallenge() { + return $this->_challenge; + } + + /** + * generates the object for a key registration + * provide this data to navigator.credentials.create + * @param string $userId + * @param string $userName + * @param string $userDisplayName + * @param int $timeout timeout in seconds + * @param bool|string $requireResidentKey 'required', if the key should be stored by the authentication device + * Valid values: + * true = required + * false = preferred + * string 'required' 'preferred' 'discouraged' + * @param bool|string $requireUserVerification indicates that you require user verification and will fail the operation + * if the response does not have the UV flag set. + * Valid values: + * true = required + * false = preferred + * string 'required' 'preferred' 'discouraged' + * @param bool|null $crossPlatformAttachment true for cross-platform devices (eg. fido usb), + * false for platform devices (eg. windows hello, android safetynet), + * null for both + * @param array $excludeCredentialIds a array of ids, which are already registered, to prevent re-registration + * @return \stdClass + */ + public function getCreateArgs($userId, $userName, $userDisplayName, $timeout=20, $requireResidentKey=false, $requireUserVerification=false, $crossPlatformAttachment=null, $excludeCredentialIds=[]) { + + $args = new \stdClass(); + $args->publicKey = new \stdClass(); + + // relying party + $args->publicKey->rp = new \stdClass(); + $args->publicKey->rp->name = $this->_rpName; + $args->publicKey->rp->id = $this->_rpId; + + $args->publicKey->authenticatorSelection = new \stdClass(); + $args->publicKey->authenticatorSelection->userVerification = 'preferred'; + + // validate User Verification Requirement + if (\is_bool($requireUserVerification)) { + $args->publicKey->authenticatorSelection->userVerification = $requireUserVerification ? 'required' : 'preferred'; + + } else if (\is_string($requireUserVerification) && \in_array(\strtolower($requireUserVerification), ['required', 'preferred', 'discouraged'])) { + $args->publicKey->authenticatorSelection->userVerification = \strtolower($requireUserVerification); + } + + // validate Resident Key Requirement + if (\is_bool($requireResidentKey) && $requireResidentKey) { + $args->publicKey->authenticatorSelection->requireResidentKey = true; + $args->publicKey->authenticatorSelection->residentKey = 'required'; + + } else if (\is_string($requireResidentKey) && \in_array(\strtolower($requireResidentKey), ['required', 'preferred', 'discouraged'])) { + $requireResidentKey = \strtolower($requireResidentKey); + $args->publicKey->authenticatorSelection->residentKey = $requireResidentKey; + $args->publicKey->authenticatorSelection->requireResidentKey = $requireResidentKey === 'required'; + } + + // filte authenticators attached with the specified authenticator attachment modality + if (\is_bool($crossPlatformAttachment)) { + $args->publicKey->authenticatorSelection->authenticatorAttachment = $crossPlatformAttachment ? 'cross-platform' : 'platform'; + } + + // user + $args->publicKey->user = new \stdClass(); + $args->publicKey->user->id = new ByteBuffer($userId); // binary + $args->publicKey->user->name = $userName; + $args->publicKey->user->displayName = $userDisplayName; + + // supported algorithms + $args->publicKey->pubKeyCredParams = []; + + if (function_exists('sodium_crypto_sign_verify_detached') || \in_array('ed25519', \openssl_get_curve_names(), true)) { + $tmp = new \stdClass(); + $tmp->type = 'public-key'; + $tmp->alg = -8; // EdDSA + $args->publicKey->pubKeyCredParams[] = $tmp; + unset ($tmp); + } + + if (\in_array('prime256v1', \openssl_get_curve_names(), true)) { + $tmp = new \stdClass(); + $tmp->type = 'public-key'; + $tmp->alg = -7; // ES256 + $args->publicKey->pubKeyCredParams[] = $tmp; + unset ($tmp); + } + + $tmp = new \stdClass(); + $tmp->type = 'public-key'; + $tmp->alg = -257; // RS256 + $args->publicKey->pubKeyCredParams[] = $tmp; + unset ($tmp); + + // if there are root certificates added, we need direct attestation to validate + // against the root certificate. If there are no root-certificates added, + // anonymization ca are also accepted, because we can't validate the root anyway. + $attestation = 'indirect'; + if (\is_array($this->_caFiles)) { + $attestation = 'direct'; + } + + $args->publicKey->attestation = \count($this->_formats) === 1 && \in_array('none', $this->_formats) ? 'none' : $attestation; + $args->publicKey->extensions = new \stdClass(); + $args->publicKey->extensions->exts = true; + $args->publicKey->timeout = $timeout * 1000; // microseconds + $args->publicKey->challenge = $this->_createChallenge(); // binary + + //prevent re-registration by specifying existing credentials + $args->publicKey->excludeCredentials = []; + + if (is_array($excludeCredentialIds)) { + foreach ($excludeCredentialIds as $id) { + $tmp = new \stdClass(); + $tmp->id = $id instanceof ByteBuffer ? $id : new ByteBuffer($id); // binary + $tmp->type = 'public-key'; + $tmp->transports = array('usb', 'nfc', 'ble', 'hybrid', 'internal'); + $args->publicKey->excludeCredentials[] = $tmp; + unset ($tmp); + } + } + + return $args; + } + + /** + * generates the object for key validation + * Provide this data to navigator.credentials.get + * @param array $credentialIds binary + * @param int $timeout timeout in seconds + * @param bool $allowUsb allow removable USB + * @param bool $allowNfc allow Near Field Communication (NFC) + * @param bool $allowBle allow Bluetooth + * @param bool $allowHybrid allow a combination of (often separate) data-transport and proximity mechanisms. + * @param bool $allowInternal allow client device-specific transport. These authenticators are not removable from the client device. + * @param bool|string $requireUserVerification indicates that you require user verification and will fail the operation + * if the response does not have the UV flag set. + * Valid values: + * true = required + * false = preferred + * string 'required' 'preferred' 'discouraged' + * @return \stdClass + */ + public function getGetArgs($credentialIds=[], $timeout=20, $allowUsb=true, $allowNfc=true, $allowBle=true, $allowHybrid=true, $allowInternal=true, $requireUserVerification=false) { + + // validate User Verification Requirement + if (\is_bool($requireUserVerification)) { + $requireUserVerification = $requireUserVerification ? 'required' : 'preferred'; + } else if (\is_string($requireUserVerification) && \in_array(\strtolower($requireUserVerification), ['required', 'preferred', 'discouraged'])) { + $requireUserVerification = \strtolower($requireUserVerification); + } else { + $requireUserVerification = 'preferred'; + } + + $args = new \stdClass(); + $args->publicKey = new \stdClass(); + $args->publicKey->timeout = $timeout * 1000; // microseconds + $args->publicKey->challenge = $this->_createChallenge(); // binary + $args->publicKey->userVerification = $requireUserVerification; + $args->publicKey->rpId = $this->_rpId; + + if (\is_array($credentialIds) && \count($credentialIds) > 0) { + $args->publicKey->allowCredentials = []; + + foreach ($credentialIds as $id) { + $tmp = new \stdClass(); + $tmp->id = $id instanceof ByteBuffer ? $id : new ByteBuffer($id); // binary + $tmp->transports = []; + + if ($allowUsb) { + $tmp->transports[] = 'usb'; + } + if ($allowNfc) { + $tmp->transports[] = 'nfc'; + } + if ($allowBle) { + $tmp->transports[] = 'ble'; + } + if ($allowHybrid) { + $tmp->transports[] = 'hybrid'; + } + if ($allowInternal) { + $tmp->transports[] = 'internal'; + } + + $tmp->type = 'public-key'; + $args->publicKey->allowCredentials[] = $tmp; + unset ($tmp); + } + } + + return $args; + } + + /** + * returns the new signature counter value. + * returns null if there is no counter + * @return ?int + */ + public function getSignatureCounter() { + return \is_int($this->_signatureCounter) ? $this->_signatureCounter : null; + } + + /** + * process a create request and returns data to save for future logins + * @param string $clientDataJSON binary from browser + * @param string $attestationObject binary from browser + * @param string|ByteBuffer $challenge binary used challange + * @param bool $requireUserVerification true, if the device must verify user (e.g. by biometric data or pin) + * @param bool $requireUserPresent false, if the device must NOT check user presence (e.g. by pressing a button) + * @param bool $failIfRootMismatch false, if there should be no error thrown if root certificate doesn't match + * @param bool $requireCtsProfileMatch false, if you don't want to check if the device is approved as a Google-certified Android device. + * @return \stdClass + * @throws WebAuthnException + */ + public function processCreate($clientDataJSON, $attestationObject, $challenge, $requireUserVerification=false, $requireUserPresent=true, $failIfRootMismatch=true, $requireCtsProfileMatch=true) { + $clientDataHash = \hash('sha256', $clientDataJSON, true); + $clientData = \json_decode($clientDataJSON); + $challenge = $challenge instanceof ByteBuffer ? $challenge : new ByteBuffer($challenge); + + // security: https://www.w3.org/TR/webauthn/#registering-a-new-credential + + // 2. Let C, the client data claimed as collected during the credential creation, + // be the result of running an implementation-specific JSON parser on JSONtext. + if (!\is_object($clientData)) { + throw new WebAuthnException('invalid client data', WebAuthnException::INVALID_DATA); + } + + // 3. Verify that the value of C.type is webauthn.create. + if (!\property_exists($clientData, 'type') || $clientData->type !== 'webauthn.create') { + throw new WebAuthnException('invalid type', WebAuthnException::INVALID_TYPE); + } + + // 4. Verify that the value of C.challenge matches the challenge that was sent to the authenticator in the create() call. + if (!\property_exists($clientData, 'challenge') || ByteBuffer::fromBase64Url($clientData->challenge)->getBinaryString() !== $challenge->getBinaryString()) { + throw new WebAuthnException('invalid challenge', WebAuthnException::INVALID_CHALLENGE); + } + + // 5. Verify that the value of C.origin matches the Relying Party's origin. + if (!\property_exists($clientData, 'origin') || !$this->_checkOrigin($clientData->origin)) { + throw new WebAuthnException('invalid origin', WebAuthnException::INVALID_ORIGIN); + } + + // Attestation + $attestationObject = new Attestation\AttestationObject($attestationObject, $this->_formats); + + // 9. Verify that the RP ID hash in authData is indeed the SHA-256 hash of the RP ID expected by the RP. + if (!$attestationObject->validateRpIdHash($this->_rpIdHash)) { + throw new WebAuthnException('invalid rpId hash', WebAuthnException::INVALID_RELYING_PARTY); + } + + // 14. Verify that attStmt is a correct attestation statement, conveying a valid attestation signature + if (!$attestationObject->validateAttestation($clientDataHash)) { + throw new WebAuthnException('invalid certificate signature', WebAuthnException::INVALID_SIGNATURE); + } + + // Android-SafetyNet: if required, check for Compatibility Testing Suite (CTS). + if ($requireCtsProfileMatch && $attestationObject->getAttestationFormat() instanceof Attestation\Format\AndroidSafetyNet) { + if (!$attestationObject->getAttestationFormat()->ctsProfileMatch()) { + throw new WebAuthnException('invalid ctsProfileMatch: device is not approved as a Google-certified Android device.', WebAuthnException::ANDROID_NOT_TRUSTED); + } + } + + // 15. If validation is successful, obtain a list of acceptable trust anchors + $rootValid = is_array($this->_caFiles) ? $attestationObject->validateRootCertificate($this->_caFiles) : null; + if ($failIfRootMismatch && is_array($this->_caFiles) && !$rootValid) { + throw new WebAuthnException('invalid root certificate', WebAuthnException::CERTIFICATE_NOT_TRUSTED); + } + + // 10. Verify that the User Present bit of the flags in authData is set. + $userPresent = $attestationObject->getAuthenticatorData()->getUserPresent(); + if ($requireUserPresent && !$userPresent) { + throw new WebAuthnException('user not present during authentication', WebAuthnException::USER_PRESENT); + } + + // 11. If user verification is required for this registration, verify that the User Verified bit of the flags in authData is set. + $userVerified = $attestationObject->getAuthenticatorData()->getUserVerified(); + if ($requireUserVerification && !$userVerified) { + throw new WebAuthnException('user not verified during authentication', WebAuthnException::USER_VERIFICATED); + } + + $signCount = $attestationObject->getAuthenticatorData()->getSignCount(); + if ($signCount > 0) { + $this->_signatureCounter = $signCount; + } + + // prepare data to store for future logins + $data = new \stdClass(); + $data->rpId = $this->_rpId; + $data->attestationFormat = $attestationObject->getAttestationFormatName(); + $data->credentialId = $attestationObject->getAuthenticatorData()->getCredentialId(); + $data->credentialPublicKey = $attestationObject->getAuthenticatorData()->getPublicKeyPem(); + $data->certificateChain = $attestationObject->getCertificateChain(); + $data->certificate = $attestationObject->getCertificatePem(); + $data->certificateIssuer = $attestationObject->getCertificateIssuer(); + $data->certificateSubject = $attestationObject->getCertificateSubject(); + $data->signatureCounter = $this->_signatureCounter; + $data->AAGUID = $attestationObject->getAuthenticatorData()->getAAGUID(); + $data->rootValid = $rootValid; + $data->userPresent = $userPresent; + $data->userVerified = $userVerified; + return $data; + } + + + /** + * process a get request + * @param string $clientDataJSON binary from browser + * @param string $authenticatorData binary from browser + * @param string $signature binary from browser + * @param string $credentialPublicKey string PEM-formated public key from used credentialId + * @param string|ByteBuffer $challenge binary from used challange + * @param int $prevSignatureCnt signature count value of the last login + * @param bool $requireUserVerification true, if the device must verify user (e.g. by biometric data or pin) + * @param bool $requireUserPresent true, if the device must check user presence (e.g. by pressing a button) + * @return boolean true if get is successful + * @throws WebAuthnException + */ + public function processGet($clientDataJSON, $authenticatorData, $signature, $credentialPublicKey, $challenge, $prevSignatureCnt=null, $requireUserVerification=false, $requireUserPresent=true) { + $authenticatorObj = new Attestation\AuthenticatorData($authenticatorData); + $clientDataHash = \hash('sha256', $clientDataJSON, true); + $clientData = \json_decode($clientDataJSON); + $challenge = $challenge instanceof ByteBuffer ? $challenge : new ByteBuffer($challenge); + + // https://www.w3.org/TR/webauthn/#verifying-assertion + + // 1. If the allowCredentials option was given when this authentication ceremony was initiated, + // verify that credential.id identifies one of the public key credentials that were listed in allowCredentials. + // -> TO BE VERIFIED BY IMPLEMENTATION + + // 2. If credential.response.userHandle is present, verify that the user identified + // by this value is the owner of the public key credential identified by credential.id. + // -> TO BE VERIFIED BY IMPLEMENTATION + + // 3. Using credential’s id attribute (or the corresponding rawId, if base64url encoding is + // inappropriate for your use case), look up the corresponding credential public key. + // -> TO BE LOOKED UP BY IMPLEMENTATION + + // 5. Let JSONtext be the result of running UTF-8 decode on the value of cData. + if (!\is_object($clientData)) { + throw new WebAuthnException('invalid client data', WebAuthnException::INVALID_DATA); + } + + // 7. Verify that the value of C.type is the string webauthn.get. + if (!\property_exists($clientData, 'type') || $clientData->type !== 'webauthn.get') { + throw new WebAuthnException('invalid type', WebAuthnException::INVALID_TYPE); + } + + // 8. Verify that the value of C.challenge matches the challenge that was sent to the + // authenticator in the PublicKeyCredentialRequestOptions passed to the get() call. + if (!\property_exists($clientData, 'challenge') || ByteBuffer::fromBase64Url($clientData->challenge)->getBinaryString() !== $challenge->getBinaryString()) { + throw new WebAuthnException('invalid challenge', WebAuthnException::INVALID_CHALLENGE); + } + + // 9. Verify that the value of C.origin matches the Relying Party's origin. + if (!\property_exists($clientData, 'origin') || !$this->_checkOrigin($clientData->origin)) { + throw new WebAuthnException('invalid origin', WebAuthnException::INVALID_ORIGIN); + } + + // 11. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the Relying Party. + if ($authenticatorObj->getRpIdHash() !== $this->_rpIdHash) { + throw new WebAuthnException('invalid rpId hash', WebAuthnException::INVALID_RELYING_PARTY); + } + + // 12. Verify that the User Present bit of the flags in authData is set + if ($requireUserPresent && !$authenticatorObj->getUserPresent()) { + throw new WebAuthnException('user not present during authentication', WebAuthnException::USER_PRESENT); + } + + // 13. If user verification is required for this assertion, verify that the User Verified bit of the flags in authData is set. + if ($requireUserVerification && !$authenticatorObj->getUserVerified()) { + throw new WebAuthnException('user not verificated during authentication', WebAuthnException::USER_VERIFICATED); + } + + // 14. Verify the values of the client extension outputs + // (extensions not implemented) + + // 16. Using the credential public key looked up in step 3, verify that sig is a valid signature + // over the binary concatenation of authData and hash. + $dataToVerify = ''; + $dataToVerify .= $authenticatorData; + $dataToVerify .= $clientDataHash; + + if (!$this->_verifySignature($dataToVerify, $signature, $credentialPublicKey)) { + throw new WebAuthnException('invalid signature', WebAuthnException::INVALID_SIGNATURE); + } + + $signatureCounter = $authenticatorObj->getSignCount(); + if ($signatureCounter !== 0) { + $this->_signatureCounter = $signatureCounter; + } + + // 17. If either of the signature counter value authData.signCount or + // previous signature count is nonzero, and if authData.signCount + // less than or equal to previous signature count, it's a signal + // that the authenticator may be cloned + if ($prevSignatureCnt !== null) { + if ($signatureCounter !== 0 || $prevSignatureCnt !== 0) { + if ($prevSignatureCnt >= $signatureCounter) { + throw new WebAuthnException('signature counter not valid', WebAuthnException::SIGNATURE_COUNTER); + } + } + } + + return true; + } + + /** + * Downloads root certificates from FIDO Alliance Metadata Service (MDS) to a specific folder + * https://fidoalliance.org/metadata/ + * @param string $certFolder Folder path to save the certificates in PEM format. + * @param bool $deleteCerts delete certificates in the target folder before adding the new ones. + * @return int number of cetificates + * @throws WebAuthnException + */ + public function queryFidoMetaDataService($certFolder, $deleteCerts=true) { + $url = 'https://mds.fidoalliance.org/'; + $raw = null; + if (\function_exists('curl_init')) { + $ch = \curl_init($url); + \curl_setopt($ch, CURLOPT_HEADER, false); + \curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + \curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + \curl_setopt($ch, CURLOPT_USERAGENT, 'github.com/lbuchs/WebAuthn - A simple PHP WebAuthn server library'); + $raw = \curl_exec($ch); + \curl_close($ch); + } else { + $raw = \file_get_contents($url); + } + + $certFolder = \rtrim(\realpath($certFolder), '\\/'); + if (!is_dir($certFolder)) { + throw new WebAuthnException('Invalid folder path for query FIDO Alliance Metadata Service'); + } + + if (!\is_string($raw)) { + throw new WebAuthnException('Unable to query FIDO Alliance Metadata Service'); + } + + $jwt = \explode('.', $raw); + if (\count($jwt) !== 3) { + throw new WebAuthnException('Invalid JWT from FIDO Alliance Metadata Service'); + } + + if ($deleteCerts) { + foreach (\scandir($certFolder) as $ca) { + if (\substr($ca, -4) === '.pem') { + if (\unlink($certFolder . DIRECTORY_SEPARATOR . $ca) === false) { + throw new WebAuthnException('Cannot delete certs in folder for FIDO Alliance Metadata Service'); + } + } + } + } + + list($header, $payload, $hash) = $jwt; + $payload = Binary\ByteBuffer::fromBase64Url($payload)->getJson(); + + $count = 0; + if (\is_object($payload) && \property_exists($payload, 'entries') && \is_array($payload->entries)) { + foreach ($payload->entries as $entry) { + if (\is_object($entry) && \property_exists($entry, 'metadataStatement') && \is_object($entry->metadataStatement)) { + $description = $entry->metadataStatement->description ?? null; + $attestationRootCertificates = $entry->metadataStatement->attestationRootCertificates ?? null; + + if ($description && $attestationRootCertificates) { + + // create filename + $certFilename = \preg_replace('/[^a-z0-9]/i', '_', $description); + $certFilename = \trim(\preg_replace('/\_{2,}/i', '_', $certFilename),'_') . '.pem'; + $certFilename = \strtolower($certFilename); + + // add certificate + $certContent = $description . "\n"; + $certContent .= \str_repeat('-', \mb_strlen($description)) . "\n"; + + foreach ($attestationRootCertificates as $attestationRootCertificate) { + $attestationRootCertificate = \str_replace(["\n", "\r", ' '], '', \trim($attestationRootCertificate)); + $count++; + $certContent .= "\n-----BEGIN CERTIFICATE-----\n"; + $certContent .= \chunk_split($attestationRootCertificate, 64, "\n"); + $certContent .= "-----END CERTIFICATE-----\n"; + } + + if (\file_put_contents($certFolder . DIRECTORY_SEPARATOR . $certFilename, $certContent) === false) { + throw new WebAuthnException('unable to save certificate from FIDO Alliance Metadata Service'); + } + } + } + } + } + + return $count; + } + + // ----------------------------------------------- + // PRIVATE + // ----------------------------------------------- + + /** + * checks if the origin matchs the RP ID + * @param string $origin + * @return boolean + * @throws WebAuthnException + */ + private function _checkOrigin($origin) { + // https://www.w3.org/TR/webauthn/#rp-id + + // The origin's scheme must be https + if ($this->_rpId !== 'localhost' && \parse_url($origin, PHP_URL_SCHEME) !== 'https') { + return false; + } + + // extract host from origin + $host = \parse_url($origin, PHP_URL_HOST); + $host = \trim($host, '.'); + + // The RP ID must be equal to the origin's effective domain, or a registrable + // domain suffix of the origin's effective domain. + return \preg_match('/' . \preg_quote($this->_rpId) . '$/i', $host) === 1; + } + + /** + * generates a new challange + * @param int $length + * @return string + * @throws WebAuthnException + */ + private function _createChallenge($length = 32) { + if (!$this->_challenge) { + $this->_challenge = ByteBuffer::randomBuffer($length); + } + return $this->_challenge; + } + + /** + * check if the signature is valid. + * @param string $dataToVerify + * @param string $signature + * @param string $credentialPublicKey PEM format + * @return bool + */ + private function _verifySignature($dataToVerify, $signature, $credentialPublicKey) { + + // Use Sodium to verify EdDSA 25519 as its not yet supported by openssl + if (\function_exists('sodium_crypto_sign_verify_detached') && !\in_array('ed25519', \openssl_get_curve_names(), true)) { + $pkParts = []; + if (\preg_match('/BEGIN PUBLIC KEY\-+(?:\s|\n|\r)+([^\-]+)(?:\s|\n|\r)*\-+END PUBLIC KEY/i', $credentialPublicKey, $pkParts)) { + $rawPk = \base64_decode($pkParts[1]); + + // 30 = der sequence + // 2a = length 42 byte + // 30 = der sequence + // 05 = lenght 5 byte + // 06 = der OID + // 03 = OID length 3 byte + // 2b 65 70 = OID 1.3.101.112 curveEd25519 (EdDSA 25519 signature algorithm) + // 03 = der bit string + // 21 = length 33 byte + // 00 = null padding + // [...] = 32 byte x-curve + $okpPrefix = "\x30\x2a\x30\x05\x06\x03\x2b\x65\x70\x03\x21\x00"; + + if ($rawPk && \strlen($rawPk) === 44 && \substr($rawPk,0, \strlen($okpPrefix)) === $okpPrefix) { + $publicKeyXCurve = \substr($rawPk, \strlen($okpPrefix)); + + return \sodium_crypto_sign_verify_detached($signature, $dataToVerify, $publicKeyXCurve); + } + } + } + + // verify with openSSL + $publicKey = \openssl_pkey_get_public($credentialPublicKey); + if ($publicKey === false) { + throw new WebAuthnException('public key invalid', WebAuthnException::INVALID_PUBLIC_KEY); + } + + return \openssl_verify($dataToVerify, $signature, $publicKey, OPENSSL_ALGO_SHA256) === 1; + } +} diff --git a/src/server/cyberhex-code/test/WebAuthnException.php b/src/server/cyberhex-code/test/WebAuthnException.php new file mode 100644 index 0000000..f27eeec --- /dev/null +++ b/src/server/cyberhex-code/test/WebAuthnException.php @@ -0,0 +1,28 @@ +addRootCertificates('rootCertificates/solo.pem'); + } + if (filter_input(INPUT_GET, 'apple')) { + $WebAuthn->addRootCertificates('rootCertificates/apple.pem'); + } + if (filter_input(INPUT_GET, 'yubico')) { + $WebAuthn->addRootCertificates('rootCertificates/yubico.pem'); + } + if (filter_input(INPUT_GET, 'hypersecu')) { + $WebAuthn->addRootCertificates('rootCertificates/hypersecu.pem'); + } + if (filter_input(INPUT_GET, 'google')) { + $WebAuthn->addRootCertificates('rootCertificates/globalSign.pem'); + $WebAuthn->addRootCertificates('rootCertificates/googleHardware.pem'); + } + if (filter_input(INPUT_GET, 'microsoft')) { + $WebAuthn->addRootCertificates('rootCertificates/microsoftTpmCollection.pem'); + } + if (filter_input(INPUT_GET, 'mds')) { + $WebAuthn->addRootCertificates('rootCertificates/mds'); + } + + } + + // ------------------------------------ + // request for create arguments + // ------------------------------------ + + if ($fn === 'getCreateArgs') { + $createArgs = $WebAuthn->getCreateArgs(\hex2bin($userId), $userName, $userDisplayName, 60*4, $requireResidentKey, $userVerification, $crossPlatformAttachment); + + header('Content-Type: application/json'); + print(json_encode($createArgs)); + + // save challange to session. you have to deliver it to processGet later. + $_SESSION['challenge'] = $WebAuthn->getChallenge(); + + + + // ------------------------------------ + // request for get arguments + // ------------------------------------ + + } else if ($fn === 'getGetArgs') { + $ids = []; + + if ($requireResidentKey) { + if (!isset($_SESSION['registrations']) || !is_array($_SESSION['registrations']) || count($_SESSION['registrations']) === 0) { + throw new Exception('we do not have any registrations in session to check the registration'); + } + + } else { + // load registrations from session stored there by processCreate. + // normaly you have to load the credential Id's for a username + // from the database. + if (isset($_SESSION['registrations']) && is_array($_SESSION['registrations'])) { + foreach ($_SESSION['registrations'] as $reg) { + if ($reg->userId === $userId) { + $ids[] = $reg->credentialId; + } + } + } + + if (count($ids) === 0) { + throw new Exception('no registrations in session for userId ' . $userId); + } + } + + $getArgs = $WebAuthn->getGetArgs($ids, 60*4, $typeUsb, $typeNfc, $typeBle, $typeHyb, $typeInt, $userVerification); + + header('Content-Type: application/json'); + print(json_encode($getArgs)); + + // save challange to session. you have to deliver it to processGet later. + $_SESSION['challenge'] = $WebAuthn->getChallenge(); + + + + // ------------------------------------ + // process create + // ------------------------------------ + + } else if ($fn === 'processCreate') { + $clientDataJSON = base64_decode($post->clientDataJSON); + $attestationObject = base64_decode($post->attestationObject); + $challenge = $_SESSION['challenge']; + + // processCreate returns data to be stored for future logins. + // in this example we store it in the php session. + // Normaly you have to store the data in a database connected + // with the user name. + $data = $WebAuthn->processCreate($clientDataJSON, $attestationObject, $challenge, $userVerification === 'required', true, false); + + // add user infos + $data->userId = $userId; + $data->userName = $userName; + $data->userDisplayName = $userDisplayName; + + if (!isset($_SESSION['registrations']) || !array_key_exists('registrations', $_SESSION) || !is_array($_SESSION['registrations'])) { + $_SESSION['registrations'] = []; + } + $_SESSION['registrations'][] = $data; + + $msg = 'registration success.'; + if ($data->rootValid === false) { + $msg = 'registration ok, but certificate does not match any of the selected root ca.'; + } + + $return = new stdClass(); + $return->success = true; + $return->msg = $msg; + + header('Content-Type: application/json'); + print(json_encode($return)); + + + + // ------------------------------------ + // proccess get + // ------------------------------------ + + } else if ($fn === 'processGet') { + $clientDataJSON = base64_decode($post->clientDataJSON); + $authenticatorData = base64_decode($post->authenticatorData); + $signature = base64_decode($post->signature); + $userHandle = base64_decode($post->userHandle); + $id = base64_decode($post->id); + $challenge = $_SESSION['challenge'] ?? ''; + $credentialPublicKey = null; + + // looking up correspondending public key of the credential id + // you should also validate that only ids of the given user name + // are taken for the login. + if (isset($_SESSION['registrations']) && is_array($_SESSION['registrations'])) { + foreach ($_SESSION['registrations'] as $reg) { + if ($reg->credentialId === $id) { + $credentialPublicKey = $reg->credentialPublicKey; + break; + } + } + } + + if ($credentialPublicKey === null) { + throw new Exception('Public Key for credential ID not found!'); + } + + // if we have resident key, we have to verify that the userHandle is the provided userId at registration + if ($requireResidentKey && $userHandle !== hex2bin($reg->userId)) { + throw new \Exception('userId doesnt match (is ' . bin2hex($userHandle) . ' but expect ' . $reg->userId . ')'); + } + + // process the get request. throws WebAuthnException if it fails + $WebAuthn->processGet($clientDataJSON, $authenticatorData, $signature, $credentialPublicKey, $challenge, null, $userVerification === 'required'); + + $return = new stdClass(); + $return->success = true; + + header('Content-Type: application/json'); + print(json_encode($return)); + + // ------------------------------------ + // proccess clear registrations + // ------------------------------------ + + } else if ($fn === 'clearRegistrations') { + $_SESSION['registrations'] = null; + $_SESSION['challenge'] = null; + + $return = new stdClass(); + $return->success = true; + $return->msg = 'all registrations deleted'; + + header('Content-Type: application/json'); + print(json_encode($return)); + + // ------------------------------------ + // display stored data as HTML + // ------------------------------------ + + } else if ($fn === 'getStoredDataHtml') { + $html = '' . "\n"; + $html .= ''; + $html .= ''; + if (isset($_SESSION['registrations']) && is_array($_SESSION['registrations'])) { + $html .= '

There are ' . count($_SESSION['registrations']) . ' registrations in this session:

'; + foreach ($_SESSION['registrations'] as $reg) { + $html .= ''; + foreach ($reg as $key => $value) { + + if (is_bool($value)) { + $value = $value ? 'yes' : 'no'; + + } else if (is_null($value)) { + $value = 'null'; + + } else if (is_object($value)) { + $value = chunk_split(strval($value), 64); + + } else if (is_string($value) && strlen($value) > 0 && htmlspecialchars($value, ENT_QUOTES) === '') { + $value = chunk_split(bin2hex($value), 64); + } + $html .= ''; + } + $html .= '
' . htmlspecialchars($key) . '' . nl2br(htmlspecialchars($value)) . '
'; + } + } else { + $html .= '

There are no registrations in this session.

'; + } + $html .= ''; + + header('Content-Type: text/html'); + print $html; + + // ------------------------------------ + // get root certs from FIDO Alliance Metadata Service + // ------------------------------------ + + } else if ($fn === 'queryFidoMetaDataService') { + + $mdsFolder = 'rootCertificates/mds'; + $success = false; + $msg = null; + + // fetch only 1x / 24h + $lastFetch = \is_file($mdsFolder . '/lastMdsFetch.txt') ? \strtotime(\file_get_contents($mdsFolder . '/lastMdsFetch.txt')) : 0; + if ($lastFetch + (3600*48) < \time()) { + $cnt = $WebAuthn->queryFidoMetaDataService($mdsFolder); + $success = true; + \file_put_contents($mdsFolder . '/lastMdsFetch.txt', date('r')); + $msg = 'successfully queried FIDO Alliance Metadata Service - ' . $cnt . ' certificates downloaded.'; + + } else { + $msg = 'Fail: last fetch was at ' . date('r', $lastFetch) . ' - fetch only 1x every 48h'; + } + + $return = new stdClass(); + $return->success = $success; + $return->msg = $msg; + + header('Content-Type: application/json'); + print(json_encode($return)); + } + +} catch (Throwable $ex) { + $return = new stdClass(); + $return->success = false; + $return->msg = $ex->getMessage(); + + header('Content-Type: application/json'); + print(json_encode($return)); +} + +?> \ No newline at end of file diff --git a/src/server/cyberhex-code/test/user.html b/src/server/cyberhex-code/test/user.html new file mode 100644 index 0000000..59e4edb --- /dev/null +++ b/src/server/cyberhex-code/test/user.html @@ -0,0 +1,542 @@ + + + lbuchs/WebAuthn Test + + + + + +

lbuchs/WebAuthn

+
A simple PHP WebAuthn (FIDO2) server library.
+
+
+
 
+
 
+
Simple working demo for the lbuchs/WebAuthn library.
+
+
 
+ + + + + + + +
+ + + + + +
+
 
+ +
+ + +
+ +
 
+
Relying Party
+

A valid domain string that identifies the + WebAuthn Relying Party
on whose behalf a given registration or authentication ceremony is being performed.

+
+ + +
+ +
 
+
User
+
+ + + You get the user ID back when checking registration (as userHandle), if you're using client-side discoverable credentials. + You can identify with this ID the user who wants to login. + A user handle is an opaque byte sequence with a maximum size of 64 bytes, and is not meant to be displayed to the user. + The user handle MUST NOT contain personally identifying information about the user, such as a username or e-mail address. +
+
+ + + only for display, i.e., aiding the user in determining the difference between user accounts with similar display names. +
+
+ + + A human-palatable name for the user account, intended only for display. +
+ +
 
+
user verification
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
 
+
type of authenticator
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
 
+
attestation
+
+ + +
+
+ + +
+ +
 
+
attestation statement format
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
 
+
attestation root certificates
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
(Nothing checked = accept all)
+
 
+ +
+ +
+ +
 
+
If you select a root ca, direct attestation is required to validate your client with the root.
+ The browser may warn you that he will provide informations about your device.
+ When not checking against any root ca (deselect all certificates), + the client may change the assertion from the authenticator (for instance, using an anonymization CA),
+ the browser may not warn about providing informations about your device. +
+
+ Copyright © 2023 Lukas Buchs - license therms +
+ +
+
+
+

Here you can see what's saved on the server:

+ +
+
+ + \ No newline at end of file