From c150ac7b375fb8acfe8794155ef61245e3c86f40 Mon Sep 17 00:00:00 2001 From: andryyy Date: Sun, 15 Nov 2020 19:32:37 +0100 Subject: [PATCH] [Web] Feature (beta): Add WebAuthn support for administrators and domain administrators --- data/web/admin.php | 87 +- data/web/css/site/admin.css | 9 + data/web/css/site/user.css | 9 + data/web/inc/ajax/qitem_details.php | 1 - data/web/inc/ajax/qr_gen.php | 3 - data/web/inc/ajax/sieve_validation.php | 1 - data/web/inc/ajax/syncjob_logs.php | 1 - data/web/inc/footer.inc.php | 112 +- data/web/inc/functions.inc.php | 158 +- data/web/inc/init_db.inc.php | 27 +- .../Attestation/AttestationObject.php | 153 + .../Attestation/AuthenticatorData.php | 423 + .../Attestation/Format/AndroidKey.php | 95 + .../Attestation/Format/AndroidSafetyNet.php | 140 + .../Attestation/Format/FormatBase.php | 183 + .../lib/WebAuthn/Attestation/Format/None.php | 39 + .../WebAuthn/Attestation/Format/Packed.php | 138 + .../lib/WebAuthn/Attestation/Format/Tpm.php | 179 + .../lib/WebAuthn/Attestation/Format/U2f.php | 93 + .../inc/lib/WebAuthn/Binary/ByteBuffer.php | 255 + .../web/inc/lib/WebAuthn/CBOR/CborDecoder.php | 220 + data/web/inc/lib/WebAuthn/LICENSE | 22 + data/web/inc/lib/WebAuthn/WebAuthn.php | 487 + .../inc/lib/WebAuthn/WebAuthnException.php | 27 + .../lib/WebAuthn/rootCertificates/apple.pem | 48 + .../WebAuthn/rootCertificates/globalSign.pem | 37 + .../rootCertificates/googleHardware.pem | 130 + .../lib/WebAuthn/rootCertificates/huawei.pem | 31 + .../WebAuthn/rootCertificates/hypersecu.pem | 56 + .../microsoftTpmCollection.pem | 28844 ++++++++++++++++ .../lib/WebAuthn/rootCertificates/solo.pem | 41 + .../lib/WebAuthn/rootCertificates/yubico.pem | 42 + data/web/inc/prerequisites.inc.php | 15 + data/web/inc/triggers.inc.php | 3 + data/web/inc/vars.inc.php | 7 + data/web/index.php | 12 +- data/web/js/site/admin.js | 8 + data/web/json_api.php | 3030 +- data/web/lang/lang.de.json | 22 +- data/web/lang/lang.en.json | 22 +- data/web/modals/admin.php | 28 + data/web/modals/user.php | 28 + data/web/user.php | 56 + 43 files changed, 33820 insertions(+), 1502 deletions(-) create mode 100644 data/web/inc/lib/WebAuthn/Attestation/AttestationObject.php create mode 100644 data/web/inc/lib/WebAuthn/Attestation/AuthenticatorData.php create mode 100644 data/web/inc/lib/WebAuthn/Attestation/Format/AndroidKey.php create mode 100644 data/web/inc/lib/WebAuthn/Attestation/Format/AndroidSafetyNet.php create mode 100644 data/web/inc/lib/WebAuthn/Attestation/Format/FormatBase.php create mode 100644 data/web/inc/lib/WebAuthn/Attestation/Format/None.php create mode 100644 data/web/inc/lib/WebAuthn/Attestation/Format/Packed.php create mode 100644 data/web/inc/lib/WebAuthn/Attestation/Format/Tpm.php create mode 100644 data/web/inc/lib/WebAuthn/Attestation/Format/U2f.php create mode 100644 data/web/inc/lib/WebAuthn/Binary/ByteBuffer.php create mode 100644 data/web/inc/lib/WebAuthn/CBOR/CborDecoder.php create mode 100644 data/web/inc/lib/WebAuthn/LICENSE create mode 100644 data/web/inc/lib/WebAuthn/WebAuthn.php create mode 100644 data/web/inc/lib/WebAuthn/WebAuthnException.php create mode 100644 data/web/inc/lib/WebAuthn/rootCertificates/apple.pem create mode 100644 data/web/inc/lib/WebAuthn/rootCertificates/globalSign.pem create mode 100644 data/web/inc/lib/WebAuthn/rootCertificates/googleHardware.pem create mode 100644 data/web/inc/lib/WebAuthn/rootCertificates/huawei.pem create mode 100644 data/web/inc/lib/WebAuthn/rootCertificates/hypersecu.pem create mode 100644 data/web/inc/lib/WebAuthn/rootCertificates/microsoftTpmCollection.pem create mode 100644 data/web/inc/lib/WebAuthn/rootCertificates/solo.pem create mode 100644 data/web/inc/lib/WebAuthn/rootCertificates/yubico.pem diff --git a/data/web/admin.php b/data/web/admin.php index 928d1690..d14495f0 100644 --- a/data/web/admin.php +++ b/data/web/admin.php @@ -5,6 +5,7 @@ if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == "admi require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php'; $_SESSION['return_to'] = $_SERVER['REQUEST_URI']; $tfa_data = get_tfa(); +$fido2_data = fido2(array("action" => "get_friendly_names")); if (!isset($_SESSION['gal']) && $license_cache = $redis->Get('LICENSE_STATUS_CACHE')) { $_SESSION['gal'] = json_decode($license_cache, true); } @@ -61,34 +62,39 @@ if (!isset($_SESSION['gal']) && $license_cache = $redis->Get('LICENSE_STATUS_CAC + + - - - + +
:

- +
-
+
[]
- +
-
+
:
- @@ -97,10 +103,61 @@ if (!isset($_SESSION['gal']) && $license_cache = $redis->Get('LICENSE_STATUS_CAC
- - + + + + + + + +
+
+ +
+
+
+
+
:
+
+
-
+
+
+
+ + + -
+
@@ -466,7 +523,7 @@ if (!isset($_SESSION['gal']) && $license_cache = $redis->Get('LICENSE_STATUS_CAC
-

:

+

:

-

@@ -500,7 +557,7 @@ if (!isset($_SESSION['gal']) && $license_cache = $redis->Get('LICENSE_STATUS_CAC
-

↳ Alias-Domain:

+

↳ Alias-Domain:

-

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( "", {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/", {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'];
- +
+ +
+ + +
+
+ +