~eapl/mebo-es

056165e8d7975d039e80afefd2d17f0bdb90e883 — eapl.mx 6 months ago bf952a8
Started to implement WebAuthn/Passkeys support
7 files changed, 644 insertions(+), 1 deletions(-)

M .gitignore
M README.md
A composer.json
A composer.lock
A public/login_passkey.php
A public/webauthn.js
A public/webauthn.php
M .gitignore => .gitignore +1 -0
@@ 4,4 4,5 @@ composer.phar
php-cs-fixer.phar
makefile
tools/
vendor/
*.sqlite3
\ No newline at end of file

M README.md => README.md +1 -1
@@ 1,6 1,6 @@
# MeBo-es
MeBo is a simple message board system on the Web.
Developed in english, but with texts in spanish. Multilingual support could come later.
Developed in English, but with texts in Spanish. Multilingual support could come later.

Forked by ~eapl from
[sourcehut - mercurial repo](https://hg.sr.ht/~m15o/mebo)

A composer.json => composer.json +5 -0
@@ 0,0 1,5 @@
{
    "require": {
        "lbuchs/webauthn": "^1.1"
    }
}

A composer.lock => composer.lock +64 -0
@@ 0,0 1,64 @@
{
    "_readme": [
        "This file locks the dependencies of your project to a known state",
        "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
        "This file is @generated automatically"
    ],
    "content-hash": "2b57a79cfde46bf4e89be9e1d7d07a87",
    "packages": [
        {
            "name": "lbuchs/webauthn",
            "version": "v1.1.3",
            "source": {
                "type": "git",
                "url": "https://github.com/lbuchs/WebAuthn.git",
                "reference": "4780c7b017ccc74a023c6ae05b5847e478f5b97d"
            },
            "dist": {
                "type": "zip",
                "url": "https://api.github.com/repos/lbuchs/WebAuthn/zipball/4780c7b017ccc74a023c6ae05b5847e478f5b97d",
                "reference": "4780c7b017ccc74a023c6ae05b5847e478f5b97d",
                "shasum": ""
            },
            "require": {
                "php": ">=7.1"
            },
            "type": "library",
            "autoload": {
                "psr-4": {
                    "lbuchs\\WebAuthn\\": "src"
                }
            },
            "notification-url": "https://packagist.org/downloads/",
            "license": [
                "MIT"
            ],
            "authors": [
                {
                    "name": "Lukas Buchs",
                    "role": "Developer"
                }
            ],
            "description": "A simple PHP WebAuthn (FIDO2) server library",
            "homepage": "https://github.com/lbuchs/webauthn",
            "keywords": [
                "Authentication",
                "webauthn"
            ],
            "support": {
                "issues": "https://github.com/lbuchs/WebAuthn/issues",
                "source": "https://github.com/lbuchs/WebAuthn/tree/v1.1.3"
            },
            "time": "2022-11-21T08:46:34+00:00"
        }
    ],
    "packages-dev": [],
    "aliases": [],
    "minimum-stability": "stable",
    "stability-flags": [],
    "prefer-stable": false,
    "prefer-lowest": false,
    "platform": [],
    "platform-dev": [],
    "plugin-api-version": "2.3.0"
}

A public/login_passkey.php => public/login_passkey.php +53 -0
@@ 0,0 1,53 @@
<?php
require 'includes/app.php';

$errors = [];
$email = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $email    = $_POST['email'];
    $password = $_POST['password'];

    Validate::isEmail($email)       or $errors[] = "Wrong email";
    Validate::isPassword($password) or $errors[] = "Wrong password";

    if (!count($errors)) {
        if ($member = $BBS->getUser()->login($email, $password, $errors)) {
            $BBS->getSession()->login($member['id']);
            redirect('index.php');
        }
    }
}
?>
<?php include 'includes/header.php'; ?>
<script src="webauthn.js"></script>

<h1>Inicia sesión</h1>

<?php form_errors($errors) ?>

<form method="POST" class="column">
  <div id="login">
    <button type="button" onclick="checkRegistration()">Login with Passkey</button>
  </div>
</form>

<hr>

<form action="<?= $_SERVER['PHP_SELF'] ?>" method="post">
  <?php include 'includes/csrf.php' ?>
  <div class="form-group">
    <label for="form-name">Correo:</label>
    <input type="email" name="email" value="<?=$email?>" class="form-control" />
  </div>

  <div class="form-group">
    <label for="form-password">Contraseña:</label>
    <input type="password" name="password" class="form-control" required/>
  </div>

  <input type="submit" value="Inicia sesión"/>
  <p><a href="password-lost.php">¿Olvidaste tu contraseña anoche?</a></p>
</form>

<?php include 'includes/footer.php'; ?>

A public/webauthn.js => public/webauthn.js +190 -0
@@ 0,0 1,190 @@
/**
 * Creates a new FIDO2 registration
 * @returns {undefined}
 */
function newRegistration() {
	if (!window.fetch || !navigator.credentials || !navigator.credentials.create) {
		window.alert('Your browser is not supported. Look for one with WebAuthn or Passkey support.')
		return
	}

	// Get default args
	window.fetch(
		'webauthn.php?fn=createArgs',
		{method:'GET',cache:'no-cache'}
	).then(function(response) {
		return response.json()

	// Convert Base64 to ArrayBuffer
	}).then(function(json) {
		// Error handling
		if (json.success === false) {
			throw new Error(json.msg);
		}

		// Replace binary Base64 data with ArrayBuffer.
		// Another way to do this is the reviver function of JSON.parse()
		recursiveBase64StrToArrayBuffer(json);
		return json;

	// Create credentials
	}).then(function(createCredentialArgs) {
		//console.log(createCredentialArgs);
		return navigator.credentials.create(createCredentialArgs);

	// Convert to base64
	}).then(function(cred) {
		return {
			clientDataJSON:
				cred.response.clientDataJSON
				? arrayBufferToBase64(cred.response.clientDataJSON)
				: null,
			attestationObject:
				cred.response.attestationObject
				? arrayBufferToBase64(cred.response.attestationObject)
				: null,
		};

	// Transfer to server
	}).then(function(AuthenticatorAttestationResponse) {
		AuthenticatorAttestationResponse.masterPwd = document.getElementById('password').value
		AuthenticatorAttestationResponse = JSON.stringify(AuthenticatorAttestationResponse)

		return window.fetch('webauthn.php?fn=processCreate', {method:'POST', body: AuthenticatorAttestationResponse, cache:'no-cache'})

	// Convert to JSON
	}).then(function(response) {
		return response.json();

	// Analyze response
	}).then(function(json) {
		 if (json.success) {
			 //reloadServerPreview();
			 window.alert(json.msg || 'Registration success');
			 //console.log(json)
			 // TODO: Redirect to somewhere else
		 } else {
			 throw new Error(json.msg);
		 }

	// Catch errors
	}).catch(function(err) {
		//reloadServerPreview();
		window.alert(err.message || 'Unknown error occured');
	});
}

/**
 * Checks a FIDO2 registration
 * @returns {undefined}
 */
function checkRegistration() {
	if (!window.fetch || !navigator.credentials || !navigator.credentials.create) {
		window.alert('Your browser is not supported. Look for one with WebAuthn or Passkey support.')
		return;
	}

	// Get default args
	window.fetch('webauthn.php?fn=getGetArgs', { method: 'GET', cache: 'no-cache' }).then(function (response) {
		console.log(response.body)
		return response.json();

	// Convert Base64 to ArrayBuffer
	}).then(function(json) {

		// Error handling
		if (json.success === false) {
			throw new Error(json.msg)
		}

		// Replace binary base64 data with ArrayBuffer. a other way to do this
		// is the reviver function of JSON.parse()
		recursiveBase64StrToArrayBuffer(json)
		console.log(json)
		return json

	// Create credentials
	}).then(function (getCredentialArgs) {
		console.log('Geting navigator credentials')
		return navigator.credentials.get(getCredentialArgs)

	// Convert to Base64
	}).then(function (cred) {
		console.log('Cred:')
		console.log(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,
			userHandle: cred.response.userHandle ? arrayBufferToBase64(cred.response.userHandle) : null
		};

	// Transfer to server
	}).then(JSON.stringify).then(function (AuthenticatorAttestationResponse) {
		return window.fetch('webauthn.php?fn=processGet', {method:'POST', body: AuthenticatorAttestationResponse, cache:'no-cache'});

	// Convert to json
	}).then(function(response) {
		return response.json();

	// Analyze response
	}).then(function(json) {
		 if (json.success) {
			 //window.alert(json.msg || 'Login success');
			 window.location.replace('index.php');
		 } else {
			 throw new Error(json.msg);
		 }

	// Catch errors
	}).catch(function (err) {
		console.log('Error catched')
		window.alert(err.message || 'unknown error occured');
	});
}

/**
 * Convert RFC 1342-like Base64 strings to ArrayBuffer
 * @param {mixed} obj
 * @returns {undefined}
 */
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]);
			}
		}
	}
}

/**
 * Convert a ArrayBuffer to Base64
 * @param {ArrayBuffer} buffer
 * @returns {String}
 */
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);
}
\ No newline at end of file

A public/webauthn.php => public/webauthn.php +330 -0
@@ 0,0 1,330 @@
<?php
/*
 * Copyright (C) 2018 Lukas Buchs
 * license https://github.com/lbuchs/WebAuthn/blob/master/LICENSE MIT
 *
 * Server test script for WebAuthn library. Saves new registrations in session.
 *
 *            JAVASCRIPT            |          SERVER
 * ------------------------------------------------------------
 *
 *               REGISTRATION
 *
 *      window.fetch  ----------------->     getCreateArgs
 *                                                |
 *   navigator.credentials.create   <-------------'
 *           |
 *           '------------------------->     processCreate
 *                                                |
 *         alert ok or fail      <----------------'
 *
 * ------------------------------------------------------------
 *
 *              VALIDATION
 *
 *      window.fetch ------------------>      getGetArgs
 *                                                |
 *   navigator.credentials.get   <----------------'
 *           |
 *           '------------------------->      processGet
 *                                                |
 *         alert ok or fail      <----------------'
 *
 * ------------------------------------------------------------
 */

require_once '../vendor/autoload.php';

define('TIMEOUT', 20);
define('REGISTRATIONS_PATH', 'registrations.bin');

//$config = parse_ini_file('.config');
//$masterPassword = $config['master_password'];

// '123'
$masterPassword = '$argon2i$v=19$m=65536,t=4,p=1$M0dRcmxtVEc0STJpZkQ0TA$Kfk3l5Fjv3gG5E8gJqemuEOw5QUCttBg+LtylyBRXYU';

try {
	session_start();

	// Read get argument and post body
	$fn = filter_input(INPUT_GET, 'fn');

	$post = trim(file_get_contents('php://input'));
	if ($post) {
		$post = json_decode($post);
	}

	if ($fn !== 'getStoredDataHtml') {
		$formats = array();
		$formats[] = 'android-key';
		$formats[] = 'android-safetynet';
		$formats[] = 'apple';
		$formats[] = 'packed';
		$formats[] = 'tpm';

		// Types selected on front end
		$typeUsb = true;
		$typeNfc = true;
		$typeBle = true;
		$typeInt = true;

		// Cross-platform: true, if type internal is not allowed
		//                 false, if only internal is allowed
		//                 null, if internal and cross-platform is allowed
		$crossPlatformAttachment = null;
		if (($typeUsb || $typeNfc || $typeBle) && !$typeInt) {
			$crossPlatformAttachment = true;

		} else if (!$typeUsb && !$typeNfc && !$typeBle && $typeInt) {
			$crossPlatformAttachment = false;
		}

		// Relying party
		//$rpId = 'eapl.mx';
		//$rpId = '127.0.0.1:8000';
		$rpId = 'https://localhost';

		// New Instance of the server library. Make sure that $rpId is the domain name.
		$WebAuthn = new lbuchs\WebAuthn\WebAuthn('WebAuthn Library', $rpId, $formats);
	}

	// ------------------------------------
	// Request for create arguments
	// ------------------------------------
	if ($fn === 'createArgs') {
		// This is a random userId
		$userId = '6f333511393d6e784e550218b66c3e09';
		$userName = 'master';
		$userDisplayName = 'master';
		$requiresResidentKey = true; // Client-side discoverable Credential
		$userVerification = true;
		$crossPlatformAttachment = null;

		// Check parameters here:
		// https://github.com/lbuchs/WebAuthn/blob/master/src/WebAuthn.php#L125
		$createArgs = $WebAuthn->getCreateArgs(
			\hex2bin($userId), $userName, $userDisplayName, TIMEOUT, $requireResidentKey, $userVerification, $crossPlatformAttachment
		);

		header('Content-Type: application/json');
		print(json_encode($createArgs));

		// Save challange to session. You have to deliver it to processGet later.
		$_SESSION['challenge'] = $WebAuthn->getChallenge();

	// ------------------------------------
	// Request for get arguments
	// ------------------------------------
	} else if ($fn === 'getGetArgs') {
		$registrations = array();
		if (file_exists(REGISTRATIONS_PATH)) {
			$registrations = unserialize(file_get_contents(REGISTRATIONS_PATH));
		}

		$ids = array();
		foreach ($registrations as $key => $value) {
			$ids[] = $value->credentialId;
		}

		$userVerification = true;

		$getArgs = $WebAuthn->getGetArgs(
			$ids, TIMEOUT, $typeUsb, $typeNfc, $typeBle, $typeInt, $userVerification
		);

		header('Content-Type: application/json');
		print(json_encode($getArgs));

		// Save challange to session. You have to deliver it to processGet later.
		$_SESSION['challenge'] = $WebAuthn->getChallenge();

	// ------------------------------------
	// Process create
	// ------------------------------------
	} else if ($fn === 'processCreate') {
		// Check parameters here:
		// https://github.com/lbuchs/WebAuthn/blob/master/src/WebAuthn.php#L277
		$clientDataJSON = base64_decode($post->clientDataJSON);
		$attestationObject = base64_decode($post->attestationObject);
		$postMasterPassword = $post->masterPwd;
		$challenge = $_SESSION['challenge'];

		$requireUserVerification = true;
		$requireUserPresent = true;
		$failIfRootMismatch = false;

		if (!password_verify($postMasterPassword, $masterPassword)) {
			throw new Exception("Incorrect Master Password!");
		}

		// processCreate returns data to be stored for future logins.
		// in this example we store it in the PHP session.

		// Normaly you have to store the data in a database connected
		// with the user name.
		$data = $WebAuthn->processCreate(
			$clientDataJSON, $attestationObject, $challenge,
			$requireUserVerification, $requireUserPresent, $failIfRootMismatch
		);

		// Add user info
		//$data->userId = $userId;
		//$data->userName = $userName;
		//$data->userDisplayName = $userDisplayName;

		/*
		// Data in $data
		// See https://github.com/lbuchs/WebAuthn/blob/master/src/WebAuthn.php#L357
		$data = new \stdClass();
		$data->rpId = $this->_rpId;
		$data->attestationFormat = $attestationObject->getAttestationFormatName();
		$data->credentialId = $attestationObject->getAuthenticatorData()->getCredentialId();
		$data->credentialPublicKey = $attestationObject->getAuthenticatorData()->getPublicKeyPem();
		$data->certificateChain = $attestationObject->getCertificateChain();
		$data->certificate = $attestationObject->getCertificatePem();
		$data->certificateIssuer = $attestationObject->getCertificateIssuer();
		$data->certificateSubject = $attestationObject->getCertificateSubject();
		$data->signatureCounter = $this->_signatureCounter;
		$data->AAGUID = $attestationObject->getAuthenticatorData()->getAAGUID();
		$data->rootValid = $rootValid;
		$data->userPresent = $userPresent;
		$data->userVerified = $userVerified;
		*/

		// Load previous registrations if they exist
		$registrations = array();

		if (file_exists(REGISTRATIONS_PATH)) {
			$registrations = unserialize(file_get_contents(REGISTRATIONS_PATH));
		}

		// chunk_split(string $body, int $chunklen = 76, string $end = "\r\n"): string
		$credentialId = chunk_split(bin2hex($data->credentialId), 64, '');
		$registrations[$credentialId] = $data;

		// We are saving the registrations in a binary file
		file_put_contents(REGISTRATIONS_PATH, serialize($registrations));

		$msg = 'Registration success.';
		if ($data->rootValid === false) {
			$msg = 'Registration OK, but certificate does not match any of the selected root CA.';
		}

		$return = new stdClass();
		$return->success = true;
		$return->msg = $msg;

		header('Content-Type: application/json');
		print(json_encode($return));

	// ------------------------------------
	// Proccess get
	// ------------------------------------
	} else if ($fn === 'processGet') {
		$clientDataJSON = base64_decode($post->clientDataJSON);
		$authenticatorData = base64_decode($post->authenticatorData);
		$signature = base64_decode($post->signature);
		$userHandle = base64_decode($post->userHandle);
		$id = base64_decode($post->id);
		$challenge = $_SESSION['challenge'];

		// Looking up correspondending Public key of the Credential ID
		// you should also validate that only IDs of the given User name
		// are taken for the login.

		$registrations = array();
		if (file_exists(REGISTRATIONS_PATH)) {
			$registrations = unserialize(file_get_contents(REGISTRATIONS_PATH));
		}

		$credentialPublicKey = null;

		foreach ($registrations as $registration) {
			if ($registration->credentialId === $id) {
				$credentialPublicKey = $registration->credentialPublicKey;
			}
		}

		if ($credentialPublicKey === null) {
			throw new Exception('Public Key for credential ID not found!');
		}

		// If we have resident key, we have to verify that the userHandle
		// is the provided userId at registration
		if ($requireResidentKey && $userHandle !== hex2bin($reg->userId)) {
			throw new \Exception('userId doesnt match (is ' . bin2hex($userHandle) . ' but expect ' . $reg->userId . ')');
		}

		// Process the get request. throws WebAuthnException if it fails
		$WebAuthn->processGet($clientDataJSON, $authenticatorData, $signature, $credentialPublicKey, $challenge, null, $userVerification === 'required');

		$return = new stdClass();
		$return->success = true;

		$_SESSION['valid_session'] = true;

		header('Content-Type: application/json');
		print(json_encode($return));

	// ------------------------------------
	// Proccess clear registrations
	// ------------------------------------
	} else if ($fn === 'clearRegistrations') {
		$_SESSION['registrations'] = null;
		$_SESSION['challenge'] = null;

		$return = new stdClass();
		$return->success = true;
		$return->msg = 'all registrations deleted';

		header('Content-Type: application/json');
		print(json_encode($return));

	// ------------------------------------
	// Display stored data as HTML
	// ------------------------------------
	} else if ($fn === 'getStoredDataHtml') {
		$html = '<!DOCTYPE html>' . "\n";
		$html .= '<html><head><style>tr:nth-child(even){background-color: #f2f2f2;}</style></head>';
		$html .= '<body style="font-family:sans-serif">';
		if (isset($_SESSION['registrations']) && is_array($_SESSION['registrations'])) {
			$html .= '<p>There are ' . count($_SESSION['registrations']) . ' registrations in this session:</p>';
			foreach ($_SESSION['registrations'] as $reg) {
				$html .= '<table style="border:1px solid black;margin:10px 0;">';
				foreach ($reg as $key => $value) {

					if (is_bool($value)) {
						$value = $value ? 'yes' : 'no';
					} else if (is_null($value)) {
						$value = 'null';
					} else if (is_object($value)) {
						$value = chunk_split(strval($value), 64);
					} else if (is_string($value) && strlen($value) > 0 && htmlspecialchars($value) === '') {
						$value = chunk_split(bin2hex($value), 64);
					}

					if ($key === 'credentialId' || $key === 'AAGUID') {
						$value = chunk_split(bin2hex($value), 64);
					}

					$html .= '<tr><td>' . htmlspecialchars($key) . '</td><td style="font-family:monospace;">' . nl2br(htmlspecialchars($value)) . '</td>';
				}
				$html .= '</table>';
			}
		} else {
			$html .= '<p>There are no registrations in this session.</p>';
		}
		$html .= '</body></html>';

		header('Content-Type: text/html');
		print $html;
	}
} catch (Throwable $ex) {
	$return = new stdClass();
	$return->success = false;
	$return->msg = $ex->getMessage();

	header('Content-Type: application/json');
	print('Error: ' . json_encode($return));
}
\ No newline at end of file