@@ -500,7 +557,7 @@ if (!isset($_SESSION['gal']) && $license_cache = $redis->Get('LICENSE_STATUS_CAC
-
↳ Alias-Domain: =htmlspecialchars($alias_domain);?>
=$lang['admin']['dkim_key_missing'];?>
+
↳ Alias-Domain: =htmlspecialchars($alias_domain);?>
=$lang['admin']['dkim_key_missing'];?>
diff --git a/data/web/css/site/admin.css b/data/web/css/site/admin.css
index e06a6d03..37395a2d 100644
--- a/data/web/css/site/admin.css
+++ b/data/web/css/site/admin.css
@@ -76,4 +76,13 @@ table tbody tr td input[type="checkbox"] {
.regex-input {
font-family: Consolas,monaco,monospace;
font-size: 14px;
+}
+.label-keys {
+ font-size:100%;
+ margin: 0px !important;
+ white-space: normal !important;
+}
+.key-action {
+ font-weight:bold;
+ color:white !important;
}
\ No newline at end of file
diff --git a/data/web/css/site/user.css b/data/web/css/site/user.css
index b4641202..965562c7 100644
--- a/data/web/css/site/user.css
+++ b/data/web/css/site/user.css
@@ -57,4 +57,13 @@ table tbody tr td input[type="checkbox"] {
-moz-transform:rotateX(180deg);
-webkit-transform:rotateX(180deg);
transform:rotateX(180deg);
+}
+.label-keys {
+ font-size:100%;
+ margin: 0px !important;
+ white-space: normal !important;
+}
+.key-action {
+ font-weight:bold;
+ color:white !important;
}
\ No newline at end of file
diff --git a/data/web/inc/ajax/qitem_details.php b/data/web/inc/ajax/qitem_details.php
index 63a9cf0d..f8dc5df2 100644
--- a/data/web/inc/ajax/qitem_details.php
+++ b/data/web/inc/ajax/qitem_details.php
@@ -1,5 +1,4 @@
getQRCodeImageAsDataUri($_SESSION['mailcow_cc_username'], $_GET['token']);
}
-
?>
diff --git a/data/web/inc/ajax/sieve_validation.php b/data/web/inc/ajax/sieve_validation.php
index cb527754..eb421b4a 100644
--- a/data/web/inc/ajax/sieve_validation.php
+++ b/data/web/inc/ajax/sieve_validation.php
@@ -1,5 +1,4 @@
$(window).scroll(function() {
@@ -28,6 +30,39 @@ function setLang(sel) {
$.post( "= $_SERVER['REQUEST_URI']; ?>", {lang: sel} );
window.location.href = window.location.pathname + window.location.search;
}
+// FIDO2 functions
+function arrayBufferToBase64(buffer) {
+ let binary = '';
+ let bytes = new Uint8Array(buffer);
+ let len = bytes.byteLength;
+ for (let i = 0; i < len; i++) {
+ binary += String.fromCharCode( bytes[ i ] );
+ }
+ return window.btoa(binary);
+}
+function recursiveBase64StrToArrayBuffer(obj) {
+ let prefix = '=?BINARY?B?';
+ let suffix = '?=';
+ if (typeof obj === 'object') {
+ for (let key in obj) {
+ if (typeof obj[key] === 'string') {
+ let str = obj[key];
+ if (str.substring(0, prefix.length) === prefix && str.substring(str.length - suffix.length) === suffix) {
+ str = str.substring(prefix.length, str.length - suffix.length);
+ let binary_string = window.atob(str);
+ let len = binary_string.length;
+ let bytes = new Uint8Array(len);
+ for (let i = 0; i < len; i++) {
+ bytes[i] = binary_string.charCodeAt(i);
+ }
+ obj[key] = bytes.buffer;
+ }
+ } else {
+ recursiveBase64StrToArrayBuffer(obj[key]);
+ }
+ }
+ }
+}
$(window).load(function() {
$(".overlay").hide();
});
@@ -97,8 +132,81 @@ $(document).ready(function() {
});
});
-
- // Set TFA modals
+ // Validate FIDO2
+ $("#fido2-login").click(function(){
+ $('#fido2-alerts').html();
+ if (!window.fetch || !navigator.credentials || !navigator.credentials.create) {
+ window.alert('Browser not supported.');
+ return;
+ }
+ window.fetch("/api/v1/get/fido2-get-args", {method:'GET',cache:'no-cache'}).then(function(response) {
+ return response.json();
+ }).then(function(json) {
+ if (json.success === false) {
+ throw new Error();
+ }
+ recursiveBase64StrToArrayBuffer(json);
+ return json;
+ }).then(function(getCredentialArgs) {
+ return navigator.credentials.get(getCredentialArgs);
+ }).then(function(cred) {
+ return {
+ id: cred.rawId ? arrayBufferToBase64(cred.rawId) : null,
+ clientDataJSON: cred.response.clientDataJSON ? arrayBufferToBase64(cred.response.clientDataJSON) : null,
+ authenticatorData: cred.response.authenticatorData ? arrayBufferToBase64(cred.response.authenticatorData) : null,
+ signature : cred.response.signature ? arrayBufferToBase64(cred.response.signature) : null
+ };
+ }).then(JSON.stringify).then(function(AuthenticatorAttestationResponse) {
+ return window.fetch("/api/v1/process/fido2-args", {method:'POST', body: AuthenticatorAttestationResponse, cache:'no-cache'});
+ }).then(function(response) {
+ return response.json();
+ }).then(function(json) {
+ if (json.success) {
+ window.location = window.location.href.split("#")[0];
+ } else {
+ throw new Error();
+ }
+ }).catch(function(err) {
+ mailcow_alert_box(lang_fido2.fido2_validation_failed, "danger");
+ });
+ });
+ // Set TFA/FIDO2
+ $("#register-fido2").click(function(){
+ $("option:selected").prop("selected", false);
+ if (!window.fetch || !navigator.credentials || !navigator.credentials.create) {
+ window.alert('Browser not supported.');
+ return;
+ }
+ window.fetch("/api/v1/get/fido2-registration/= (isset($_SESSION['mailcow_cc_username'])) ? rawurlencode($_SESSION['mailcow_cc_username']) : null; ?>", {method:'GET',cache:'no-cache'}).then(function(response) {
+ return response.json();
+ }).then(function(json) {
+ if (json.success === false) {
+ throw new Error(json.msg);
+ }
+ recursiveBase64StrToArrayBuffer(json);
+ return json;
+ }).then(function(createCredentialArgs) {
+ console.log(createCredentialArgs);
+ return navigator.credentials.create(createCredentialArgs);
+ }).then(function(cred) {
+ return {
+ clientDataJSON: cred.response.clientDataJSON ? arrayBufferToBase64(cred.response.clientDataJSON) : null,
+ attestationObject: cred.response.attestationObject ? arrayBufferToBase64(cred.response.attestationObject) : null
+ };
+ }).then(JSON.stringify).then(function(AuthenticatorAttestationResponse) {
+ return window.fetch("/api/v1/add/fido2-registration", {method:'POST', body: AuthenticatorAttestationResponse, cache:'no-cache'});
+ }).then(function(response) {
+ return response.json();
+ }).then(function(json) {
+ if (json.success) {
+ window.location = window.location.href.split("#")[0];
+ } else {
+ throw new Error(json.msg);
+ }
+ }).catch(function(err) {
+ $('#fido2-alerts').html('
' + err.message + '');
+ });
+ });
$('#selectTFA').change(function () {
if ($(this).val() == "yubi_otp") {
$('#YubiOTPModal').modal('show');
diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php
index 8bf8d59f..ca1b0290 100644
--- a/data/web/inc/functions.inc.php
+++ b/data/web/inc/functions.inc.php
@@ -924,20 +924,10 @@ function set_tfa($_data) {
case "totp":
$key_id = (!isset($_data["key_id"])) ? 'unidentified' : $_data["key_id"];
if ($tfa->verifyCode($_POST['totp_secret'], $_POST['totp_confirm_token']) === true) {
- try {
$stmt = $pdo->prepare("DELETE FROM `tfa` WHERE `username` = :username");
$stmt->execute(array(':username' => $username));
$stmt = $pdo->prepare("INSERT INTO `tfa` (`username`, `key_id`, `authmech`, `secret`, `active`) VALUES (?, ?, 'totp', ?, '1')");
$stmt->execute(array($username, $key_id, $_POST['totp_secret']));
- }
- catch (PDOException $e) {
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_data_log),
- 'msg' => array('mysql_error', $e)
- );
- return false;
- }
$_SESSION['return'][] = array(
'type' => 'success',
'log' => array(__FUNCTION__, $_data_log),
@@ -953,18 +943,142 @@ function set_tfa($_data) {
}
break;
case "none":
- try {
- $stmt = $pdo->prepare("DELETE FROM `tfa` WHERE `username` = :username");
- $stmt->execute(array(':username' => $username));
- }
- catch (PDOException $e) {
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_data_log),
- 'msg' => array('mysql_error', $e)
- );
- return false;
- }
+ $stmt = $pdo->prepare("DELETE FROM `tfa` WHERE `username` = :username");
+ $stmt->execute(array(':username' => $username));
+ $_SESSION['return'][] = array(
+ 'type' => 'success',
+ 'log' => array(__FUNCTION__, $_data_log),
+ 'msg' => array('object_modified', htmlspecialchars($username))
+ );
+ break;
+ }
+}
+function fido2($_data) {
+ global $pdo;
+ $_data_log = $_data;
+ // Not logging registration data, only actions
+ // Silent errors for "get" requests
+ switch ($_data["action"]) {
+ case "register":
+ $username = $_SESSION['mailcow_cc_username'];
+ if ($_SESSION['mailcow_cc_role'] != "domainadmin" &&
+ $_SESSION['mailcow_cc_role'] != "admin") {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_data["action"]),
+ 'msg' => 'access_denied'
+ );
+ return false;
+ }
+ $stmt = $pdo->prepare("INSERT INTO `fido2` (`username`, `rpId`, `credentialPublicKey`, `certificateChain`, `certificate`, `certificateIssuer`, `certificateSubject`, `signatureCounter`, `AAGUID`, `credentialId`)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
+ $stmt->execute(array(
+ $username,
+ $_data['registration']->rpId,
+ $_data['registration']->credentialPublicKey,
+ $_data['registration']->certificateChain,
+ $_data['registration']->certificate,
+ $_data['registration']->certificateIssuer,
+ $_data['registration']->certificateSubject,
+ $_data['registration']->signatureCounter,
+ $_data['registration']->AAGUID,
+ $_data['registration']->credentialId)
+ );
+ $_SESSION['return'][] = array(
+ 'type' => 'success',
+ 'log' => array(__FUNCTION__, $_data["action"]),
+ 'msg' => array('object_modified', $username)
+ );
+ break;
+ case "get_user_cids":
+ // Used to exclude existing CredentialIds while registering
+ $username = $_SESSION['mailcow_cc_username'];
+ if ($_SESSION['mailcow_cc_role'] != "domainadmin" &&
+ $_SESSION['mailcow_cc_role'] != "admin") {
+ return false;
+ }
+ $stmt = $pdo->prepare("SELECT `credentialId` FROM `fido2` WHERE `username` = :username");
+ $stmt->execute(array(':username' => $username));
+ $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
+ while($row = array_shift($rows)) {
+ $cids[] = $row['credentialId'];
+ }
+ return $cids;
+ break;
+ case "get_all_cids":
+ // Only needed when using fido2 with username
+ $stmt = $pdo->query("SELECT `credentialId` FROM `fido2`");
+ $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
+ while($row = array_shift($rows)) {
+ $cids[] = $row['credentialId'];
+ }
+ return $cids;
+ break;
+ case "get_pub_key":
+ if (!isset($_data['cid']) || empty($_data['cid'])) {
+ return false;
+ }
+ $stmt = $pdo->prepare("SELECT `certificateSubject`, `username`, `credentialPublicKey` FROM `fido2` WHERE TO_BASE64(`credentialId`) = :cid");
+ $stmt->execute(array(':cid' => $_data['cid']));
+ $row = $stmt->fetch(PDO::FETCH_ASSOC);
+ if (empty($row) || empty($row['credentialPublicKey']) || empty($row['username'])) {
+ return false;
+ }
+ $data['pub_key'] = $row['credentialPublicKey'];
+ $data['username'] = $row['username'];
+ $data['key_id'] = $row['certificateSubject'];
+ return $data;
+ break;
+ case "get_friendly_names":
+ $username = $_SESSION['mailcow_cc_username'];
+ if ($_SESSION['mailcow_cc_role'] != "domainadmin" &&
+ $_SESSION['mailcow_cc_role'] != "admin") {
+ return false;
+ }
+ $stmt = $pdo->prepare("SELECT `certificateSubject`, `friendlyName` FROM `fido2` WHERE `username` = :username");
+ $stmt->execute(array(':username' => $username));
+ $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
+ while($row = array_shift($rows)) {
+ $fns[] = array("subject" => $row['certificateSubject'], "fn" => $row['friendlyName']);
+ }
+ return $fns;
+ break;
+ case "unset_fido2_key":
+ $username = $_SESSION['mailcow_cc_username'];
+ if ($_SESSION['mailcow_cc_role'] != "domainadmin" &&
+ $_SESSION['mailcow_cc_role'] != "admin") {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_data["action"]),
+ 'msg' => 'access_denied'
+ );
+ return false;
+ }
+ $stmt = $pdo->prepare("DELETE FROM `fido2` WHERE `username` = :username AND `certificateSubject` = :certificateSubject");
+ $stmt->execute(array(':username' => $username, ':certificateSubject' => $_data['post_data']['unset_fido2_key']));
+ $_SESSION['return'][] = array(
+ 'type' => 'success',
+ 'log' => array(__FUNCTION__, $_data_log),
+ 'msg' => array('object_modified', htmlspecialchars($username))
+ );
+ break;
+ case "edit_fn":
+ $username = $_SESSION['mailcow_cc_username'];
+ if ($_SESSION['mailcow_cc_role'] != "domainadmin" &&
+ $_SESSION['mailcow_cc_role'] != "admin") {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_data["action"]),
+ 'msg' => 'access_denied'
+ );
+ return false;
+ }
+ $stmt = $pdo->prepare("UPDATE `fido2` SET `friendlyName` = :friendlyName WHERE `certificateSubject` = :certificateSubject AND `username` = :username");
+ $stmt->execute(array(
+ ':username' => $username,
+ ':friendlyName' => $_data['fido2_attrs']['fido2_fn'],
+ ':certificateSubject' => base64_decode($_data['fido2_attrs']['fido2_subject'])
+ ));
$_SESSION['return'][] = array(
'type' => 'success',
'log' => array(__FUNCTION__, $_data_log),
diff --git a/data/web/inc/init_db.inc.php b/data/web/inc/init_db.inc.php
index 37710cba..dcc70dac 100644
--- a/data/web/inc/init_db.inc.php
+++ b/data/web/inc/init_db.inc.php
@@ -3,7 +3,7 @@ function init_db_schema() {
try {
global $pdo;
- $db_version = "06112020_1010";
+ $db_version = "15112020_1110";
$stmt = $pdo->query("SHOW TABLES LIKE 'versions'");
$num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
@@ -84,6 +84,31 @@ function init_db_schema() {
),
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
),
+ "fido2" => array(
+ "cols" => array(
+ "username" => "VARCHAR(255) NOT NULL",
+ "friendlyName" => "VARCHAR(255)",
+ "rpId" => "VARCHAR(255) NOT NULL",
+ "credentialPublicKey" => "TEXT NOT NULL",
+ "certificateChain" => "TEXT",
+ // Can be null for format "none"
+ "certificate" => "TEXT",
+ "certificateIssuer" => "VARCHAR(255)",
+ "certificateSubject" => "VARCHAR(255)",
+ "signatureCounter" => "INT",
+ "AAGUID" => "BLOB",
+ "credentialId" => "BLOB NOT NULL",
+ "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
+ "modified" => "DATETIME ON UPDATE NOW(0)",
+ "active" => "TINYINT(1) NOT NULL DEFAULT '1'"
+ ),
+ "keys" => array(
+ "unique" => array(
+ "fido2_username_CID" => array("username", "certificateSubject")
+ )
+ ),
+ "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
+ ),
"_sogo_static_view" => array(
"cols" => array(
"c_uid" => "VARCHAR(255) NOT NULL",
diff --git a/data/web/inc/lib/WebAuthn/Attestation/AttestationObject.php b/data/web/inc/lib/WebAuthn/Attestation/AttestationObject.php
new file mode 100644
index 00000000..f44a8151
--- /dev/null
+++ b/data/web/inc/lib/WebAuthn/Attestation/AttestationObject.php
@@ -0,0 +1,153 @@
+_authenticatorData = new AuthenticatorData($enc['authData']->getBinaryString());
+
+ // Format ok?
+ if (!in_array($enc['fmt'], $allowedFormats)) {
+ throw new WebAuthnException('invalid atttestation format: ' . $enc['fmt'], WebAuthnException::INVALID_DATA);
+ }
+
+ switch ($enc['fmt']) {
+ 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 '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 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) && \is_array($certInfo['issuer'])) {
+ if ($certInfo['issuer']['CN']) {
+ $issuer .= \trim($certInfo['issuer']['CN']);
+ }
+ if ($certInfo['issuer']['O'] || $certInfo['issuer']['OU']) {
+ if ($issuer) {
+ $issuer .= ' (' . \trim($certInfo['issuer']['O'] . ' ' . $certInfo['issuer']['OU']) . ')';
+ } else {
+ $issuer .= \trim($certInfo['issuer']['O'] . ' ' . $certInfo['issuer']['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) && \is_array($certInfo['subject'])) {
+ if ($certInfo['subject']['CN']) {
+ $subject .= \trim($certInfo['subject']['CN']);
+ }
+ if ($certInfo['subject']['O'] || $certInfo['subject']['OU']) {
+ if ($subject) {
+ $subject .= ' (' . \trim($certInfo['subject']['O'] . ' ' . $certInfo['subject']['OU']) . ')';
+ } else {
+ $subject .= \trim($certInfo['subject']['O'] . ' ' . $certInfo['subject']['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/data/web/inc/lib/WebAuthn/Attestation/AuthenticatorData.php b/data/web/inc/lib/WebAuthn/Attestation/AuthenticatorData.php
new file mode 100644
index 00000000..374d9ab4
--- /dev/null
+++ b/data/web/inc/lib/WebAuthn/Attestation/AuthenticatorData.php
@@ -0,0 +1,423 @@
+_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() {
+ $der = null;
+ switch ($this->_attestedCredentialData->credentialPublicKey->kty) {
+ case self::$_EC2_TYPE: $der = $this->_getEc2Der(); break;
+ case self::$_RSA_TYPE: $der = $this->_getRsaDer(); 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)) {
+ throw new WebAuthnException('credential data not included in authenticator data', WebAuthnException::INVALID_DATA);
+ }
+ 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 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;
+ }
+
+ return $credPKey;
+ }
+
+ /**
+ * 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/data/web/inc/lib/WebAuthn/Attestation/Format/AndroidKey.php b/data/web/inc/lib/WebAuthn/Attestation/Format/AndroidKey.php
new file mode 100644
index 00000000..aa6f1abb
--- /dev/null
+++ b/data/web/inc/lib/WebAuthn/Attestation/Format/AndroidKey.php
@@ -0,0 +1,95 @@
+_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/data/web/inc/lib/WebAuthn/Attestation/Format/AndroidSafetyNet.php b/data/web/inc/lib/WebAuthn/Attestation/Format/AndroidSafetyNet.php
new file mode 100644
index 00000000..70f4212a
--- /dev/null
+++ b/data/web/inc/lib/WebAuthn/Attestation/Format/AndroidSafetyNet.php
@@ -0,0 +1,140 @@
+_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 (!$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);
+ }
+ }
+
+
+ /*
+ * 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 (!$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'] || $certInfo['subject']['CN'] !== 'attest.android.com') {
+ throw new WebAuthnException('invalid certificate CN in JWS (' . $certInfo['subject']['CN']. ')', WebAuthnException::INVALID_DATA);
+ }
+
+ // Verify that the ctsProfileMatch attribute in the payload of response is true.
+ if (!$this->_payload->ctsProfileMatch) {
+ throw new WebAuthnException('invalid ctsProfileMatch 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/data/web/inc/lib/WebAuthn/Attestation/Format/FormatBase.php b/data/web/inc/lib/WebAuthn/Attestation/Format/FormatBase.php
new file mode 100644
index 00000000..a9048b9e
--- /dev/null
+++ b/data/web/inc/lib/WebAuthn/Attestation/Format/FormatBase.php
@@ -0,0 +1,183 @@
+_attestationObject = $AttestionObject;
+ $this->_authenticatorData = $authenticatorData;
+ }
+
+ /**
+ *
+ */
+ public function __destruct() {
+ // delete X.509 chain certificate file after use
+ if (\is_file($this->_x5c_tempFile)) {
+ \unlink($this->_x5c_tempFile);
+ }
+ }
+
+ /**
+ * returns the certificate chain in PEM format
+ * @return string|null
+ */
+ public function getCertificateChain() {
+ if (\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 issuer = subject (self signed)
+ if (\is_array($certInfo) && \is_array($certInfo['issuer']) && \is_array($certInfo['subject'])) {
+ $selfSigned = true;
+ foreach ($certInfo['issuer'] as $k => $v) {
+ if ($certInfo['subject'][$k] !== $v) {
+ $selfSigned = false;
+ break;
+ }
+ }
+
+ if (!$selfSigned) {
+ $content .= "\n" . $this->_createCertificatePem($x5c) . "\n";
+ }
+ }
+ }
+ }
+
+ if ($content) {
+ $this->_x5c_tempFile = \sys_get_temp_dir() . '/x5c_chain_' . \base_convert(\rand(), 10, 36) . '.pem';
+ 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/data/web/inc/lib/WebAuthn/Attestation/Format/None.php b/data/web/inc/lib/WebAuthn/Attestation/Format/None.php
new file mode 100644
index 00000000..1664c559
--- /dev/null
+++ b/data/web/inc/lib/WebAuthn/Attestation/Format/None.php
@@ -0,0 +1,39 @@
+_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/data/web/inc/lib/WebAuthn/Attestation/Format/Tpm.php b/data/web/inc/lib/WebAuthn/Attestation/Format/Tpm.php
new file mode 100644
index 00000000..32bb7663
--- /dev/null
+++ b/data/web/inc/lib/WebAuthn/Attestation/Format/Tpm.php
@@ -0,0 +1,179 @@
+_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/data/web/inc/lib/WebAuthn/Attestation/Format/U2f.php b/data/web/inc/lib/WebAuthn/Attestation/Format/U2f.php
new file mode 100644
index 00000000..630b1544
--- /dev/null
+++ b/data/web/inc/lib/WebAuthn/Attestation/Format/U2f.php
@@ -0,0 +1,93 @@
+_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();
+ }
+
+
+ /*
+ * 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/data/web/inc/lib/WebAuthn/Binary/ByteBuffer.php b/data/web/inc/lib/WebAuthn/Binary/ByteBuffer.php
new file mode 100644
index 00000000..dd0eec7b
--- /dev/null
+++ b/data/web/inc/lib/WebAuthn/Binary/ByteBuffer.php
@@ -0,0 +1,255 @@
+_data = $binaryData;
+ $this->_length = \strlen($binaryData);
+ }
+
+
+ // -----------------------
+ // PUBLIC STATIC
+ // -----------------------
+
+ /**
+ * create a ByteBuffer from a base64 url encoded string
+ * @param string $base64url
+ * @return \WebAuthn\Binary\ByteBuffer
+ */
+ public static function fromBase64Url($base64url) {
+ $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 \WebAuthn\Binary\ByteBuffer
+ */
+ public static function fromHex($hex) {
+ $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 \WebAuthn\Binary\ByteBuffer
+ */
+ public static function randomBuffer($length) {
+ 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) {
+ 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) {
+ if ($offset < 0 || $offset >= $this->_length) {
+ throw new WebAuthnException('ByteBuffer: Invalid offset', WebAuthnException::BYTEBUFFER);
+ }
+ return \ord(\substr($this->_data, $offset, 1));
+ }
+
+ public function getLength() {
+ 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() {
+ return $this->_data;
+ }
+
+ /**
+ * @param string $buffer
+ * @return bool
+ */
+ public function equals($buffer) {
+ return is_string($this->_data) && $this->_data === $buffer->data;
+ }
+
+ /**
+ * @return string
+ */
+ public function getHex() {
+ return \bin2hex($this->_data);
+ }
+
+ /**
+ * @return bool
+ */
+ public function isEmpty() {
+ return $this->_length === 0;
+ }
+
+
+ /**
+ * jsonSerialize interface
+ * return binary data in RFC 1342-Like serialized string
+ * @return \stdClass
+ */
+ public function jsonSerialize() {
+ if (ByteBuffer::$useBase64UrlEncoding) {
+ return self::_base64url_encode($this->_data);
+
+ } else {
+ return '=?BINARY?B?' . \base64_encode($this->_data) . '?=';
+ }
+ }
+
+ /**
+ * Serializable-Interface
+ * @return string
+ */
+ public function serialize() {
+ return \serialize($this->_data);
+ }
+
+ /**
+ * Serializable-Interface
+ * @param string $serialized
+ */
+ public function unserialize($serialized) {
+ $this->_data = \unserialize($serialized);
+ $this->_length = \strlen($this->_data);
+ }
+
+ // -----------------------
+ // PROTECTED STATIC
+ // -----------------------
+
+ /**
+ * base64 url decoding
+ * @param string $data
+ * @return string
+ */
+ protected static function _base64url_decode($data) {
+ 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) {
+ return \rtrim(\strtr(\base64_encode($data), '+/', '-_'), '=');
+ }
+}
diff --git a/data/web/inc/lib/WebAuthn/CBOR/CborDecoder.php b/data/web/inc/lib/WebAuthn/CBOR/CborDecoder.php
new file mode 100644
index 00000000..45626eb1
--- /dev/null
+++ b/data/web/inc/lib/WebAuthn/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/data/web/inc/lib/WebAuthn/LICENSE b/data/web/inc/lib/WebAuthn/LICENSE
new file mode 100644
index 00000000..e24a2b63
--- /dev/null
+++ b/data/web/inc/lib/WebAuthn/LICENSE
@@ -0,0 +1,22 @@
+MIT License
+
+Copyright © 2019 Lukas Buchs
+Copyright © 2018 Thomas Bleeker (CBOR & ByteBuffer part)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/data/web/inc/lib/WebAuthn/WebAuthn.php b/data/web/inc/lib/WebAuthn/WebAuthn.php
new file mode 100644
index 00000000..a685fac8
--- /dev/null
+++ b/data/web/inc/lib/WebAuthn/WebAuthn.php
@@ -0,0 +1,487 @@
+_rpName = $rpName;
+ $this->_rpId = $rpId;
+ $this->_rpIdHash = \hash('sha256', $rpId, true);
+ ByteBuffer::$useBase64UrlEncoding = !!$useBase64UrlEncoding;
+
+ 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 value
+ if (!is_array($allowedFormats)) {
+ $allowedFormats = array('android-key', 'fido-u2f', 'packed', 'tpm');
+ }
+ $this->_formats = $allowedFormats;
+
+ // validate formats
+ $invalidFormats = \array_diff($this->_formats, array('android-key', 'android-safetynet', 'fido-u2f', 'none', 'packed', 'tpm'));
+ 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
+ */
+ public function addRootCertificates($path) {
+ if (!\is_array($this->_caFiles)) {
+ $this->_caFiles = array();
+ }
+ $path = \rtrim(\trim($path), '\\/');
+ if (\is_dir($path)) {
+ foreach (\scandir($path) as $ca) {
+ if (\is_file($path . '/' . $ca)) {
+ $this->addRootCertificates($path . '/' . $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 $requireResidentKey true, if the key should be stored by the authentication 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'
+ * @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, $excludeCredentialIds=array()) {
+
+ // 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();
+
+ // 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 = $requireUserVerification;
+ if ($requireResidentKey) {
+ $args->publicKey->authenticatorSelection->requireResidentKey = true;
+ }
+
+ // user
+ $args->publicKey->user = new \stdClass();
+ $args->publicKey->user->id = new ByteBuffer($userId); // binary
+ $args->publicKey->user->name = $userName;
+ $args->publicKey->user->displayName = $userDisplayName;
+
+ $args->publicKey->pubKeyCredParams = array();
+ $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 = array();
+
+ 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', 'ble', 'nfc', '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 $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=array(), $timeout=20, $allowUsb=true, $allowNfc=true, $allowBle=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 = array();
+
+ foreach ($credentialIds as $id) {
+ $tmp = new \stdClass();
+ $tmp->id = $id instanceof ByteBuffer ? $id : new ByteBuffer($id); // binary
+ $tmp->transports = array();
+
+ if ($allowUsb) {
+ $tmp->transports[] = 'usb';
+ }
+ if ($allowNfc) {
+ $tmp->transports[] = 'nfc';
+ }
+ if ($allowBle) {
+ $tmp->transports[] = 'ble';
+ }
+ 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)
+ * @return \stdClass
+ * @throws WebAuthnException
+ */
+ public function processCreate($clientDataJSON, $attestationObject, $challenge, $requireUserVerification=false, $requireUserPresent=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);
+ }
+
+ // 15. If validation is successful, obtain a list of acceptable trust anchors
+ if (is_array($this->_caFiles) && !$attestationObject->validateRootCertificate($this->_caFiles)) {
+ throw new WebAuthnException('Invalid root certificate', WebAuthnException::CERTIFICATE_NOT_TRUSTED);
+ }
+
+ // 10. Verify that the User Present bit of the flags in authData is set.
+ if ($requireUserPresent && !$attestationObject->getAuthenticatorData()->getUserPresent()) {
+ 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.
+ if ($requireUserVerification && !$attestationObject->getAuthenticatorData()->getUserVerified()) {
+ throw new WebAuthnException('User not verificated 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->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();
+ 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;
+
+ $publicKey = \openssl_pkey_get_public($credentialPublicKey);
+ if ($publicKey === false) {
+ throw new WebAuthnException('public key invalid', WebAuthnException::INVALID_PUBLIC_KEY);
+ }
+
+ if (\openssl_verify($dataToVerify, $signature, $publicKey, OPENSSL_ALGO_SHA256) !== 1) {
+ throw new WebAuthnException('Invalid signature', WebAuthnException::INVALID_SIGNATURE);
+ }
+
+ // 17. If the signature counter value authData.signCount is nonzero,
+ // if less than or equal to the signature counter value stored,
+ // is a signal that the authenticator may be cloned
+ $signatureCounter = $authenticatorObj->getSignCount();
+ if ($signatureCounter > 0) {
+ $this->_signatureCounter = $signatureCounter;
+ if ($prevSignatureCnt !== null && $prevSignatureCnt >= $signatureCounter) {
+ throw new WebAuthnException('signature counter not valid', WebAuthnException::SIGNATURE_COUNTER);
+ }
+ }
+
+ return true;
+ }
+
+ // -----------------------------------------------
+ // 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;
+ }
+}
diff --git a/data/web/inc/lib/WebAuthn/WebAuthnException.php b/data/web/inc/lib/WebAuthn/WebAuthnException.php
new file mode 100644
index 00000000..823f7d80
--- /dev/null
+++ b/data/web/inc/lib/WebAuthn/WebAuthnException.php
@@ -0,0 +1,27 @@
+addRootCertificates($_SERVER['DOCUMENT_ROOT'] . '/inc/lib/WebAuthn/rootCertificates/solo.pem');
+$WebAuthn->addRootCertificates($_SERVER['DOCUMENT_ROOT'] . '/inc/lib/WebAuthn/rootCertificates/apple.pem');
+$WebAuthn->addRootCertificates($_SERVER['DOCUMENT_ROOT'] . '/inc/lib/WebAuthn/rootCertificates/yubico.pem');
+$WebAuthn->addRootCertificates($_SERVER['DOCUMENT_ROOT'] . '/inc/lib/WebAuthn/rootCertificates/hypersecu.pem');
+$WebAuthn->addRootCertificates($_SERVER['DOCUMENT_ROOT'] . '/inc/lib/WebAuthn/rootCertificates/globalSign.pem');
+$WebAuthn->addRootCertificates($_SERVER['DOCUMENT_ROOT'] . '/inc/lib/WebAuthn/rootCertificates/googleHardware.pem');
+$WebAuthn->addRootCertificates($_SERVER['DOCUMENT_ROOT'] . '/inc/lib/WebAuthn/rootCertificates/microsoftTpmCollection.pem');
+$WebAuthn->addRootCertificates($_SERVER['DOCUMENT_ROOT'] . '/inc/lib/WebAuthn/rootCertificates/huawei.pem');
+
// Redis
$redis = new Redis();
try {
diff --git a/data/web/inc/triggers.inc.php b/data/web/inc/triggers.inc.php
index 132e0bd2..8785759a 100644
--- a/data/web/inc/triggers.inc.php
+++ b/data/web/inc/triggers.inc.php
@@ -89,6 +89,9 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
if (isset($_POST["unset_tfa_key"])) {
unset_tfa_key($_POST);
}
+ if (isset($_POST["unset_fido2_key"])) {
+ fido2(array("action" => "unset_fido2_key", "post_data" => $_POST));
+ }
}
if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == "admin") {
// TODO: Move file upload to API?
diff --git a/data/web/inc/vars.inc.php b/data/web/inc/vars.inc.php
index 993b7975..db5fbbc0 100644
--- a/data/web/inc/vars.inc.php
+++ b/data/web/inc/vars.inc.php
@@ -173,6 +173,13 @@ $MAILBOX_DEFAULT_ATTRIBUTES['mailbox_format'] = 'maildir:';
// Show last IMAP and POP3 logins
$SHOW_LAST_LOGIN = true;
+// UV flag handling in FIDO2/WebAuthn - defaults to false to allow iOS logins
+// true = required
+// false = preferred
+// string 'required' 'preferred' 'discouraged'
+$FIDO2_UV_FLAG = 'preferred';
+$FIDO2_USER_PRESENT_FLAG = true;
+$FIDO2_FORMATS = array('android-key', 'android-safetynet', 'fido-u2f', 'none', 'packed', 'tpm');
// Set visible Rspamd maps in mailcow UI, do not change unless you know what you are doing
$RSPAMD_MAPS = array(
diff --git a/data/web/index.php b/data/web/index.php
index 7a640d9d..571f1898 100644
--- a/data/web/index.php
+++ b/data/web/index.php
@@ -59,7 +59,16 @@ $_SESSION['index_query_string'] = $_SERVER['QUERY_STRING'];