Fortify With Vanilla JavaScript
This is a demo using vanilla JavaScript to communicate with the Fortify client.
PHP
<?php if ($_SERVER['SERVER_NAME'] !== 'localhost' && (empty($_SERVER['HTTPS']) || $_SERVER['HTTPS'] === 'off')) { throw new Exception('This demo must run on localhost or HTTPS.'); } ?> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>SetaPDF-Signer meets Fortify</title> <style> body { font-family: Tahoma,Verdana,Segoe,sans-serif; font-size: 14px; } #signatureControlsPanel { margin-top: 1em; } </style> </head> <body> <div id="loading">Loading...</div> <div id="outdated" style="display:none;">Your browser is outdated.</div> <div id="fortifyNotReachable" style="display:none;">Fortify is not running. <a href="https://fortifyapp.com/" target="_blank">Install</a> and start the app on your computer and reload this demo. Note: Most browsers need to run this script in https.</div> <div id="challengeExchange" style="display:none;">Please compare this pin <b>{pin}</b> with the one that Fortify displays and confirm.</div> <div id="notLoggedIn" style="display: none;">You're not logged in with Fortify.</div> <div id="signatureControlsPanel" style="display: none;"> <select id="providersSelect"><option>Loading...</option></select> <select id="certificatesSelect"><option>Loading...</option></select> <br /> <input type="checkbox" name="useAIA" id="useAIA" checked="checked"/><label for="useAIA">Embedded certificates fetched from the <a href="http://www.pkiglobe.org/auth_info_access.html" target="_blank">AIA extension</a> (only HTTP, .cer/.der (no .p7c support), no validation is done).</label><br /> <input type="checkbox" name="useTimestamp" id="useTimestamp" checked="checked" /><label for="useTimestamp">Embedded timestamp if adobe timestamp extension is available in certificate.</label><br /> <button id="signBtn" disabled="disabled">Sign Dummy File</button> <button id="downloadBtn" disabled="disabled">Download</button> <script type="text/javascript"> if (!window.Promise) { document.getElementById('outdated').style.display = ''; } else { document.addEventListener("DOMContentLoaded", function(event) { function loadScript(src) { return new Promise(function(resolve, reject) { var script = document.createElement('script'); script.src = src; script.type = 'text/javascript'; script.onload = resolve.bind(null, true); script.onerror = reject; document.head.appendChild(script); }); } loadScript('https://cdn.jsdelivr.net/npm/@babel/polyfill@7.8.3/dist/polyfill.min.js') .then(function () {return loadScript('https://cdn.jsdelivr.net/npm/asmcrypto.js@2.3.2/asmcrypto.all.es5.min.js')}) .then(function () {return loadScript('https://rawcdn.githack.com/indutny/elliptic/60489415e545efdfd3010ae74b9726facbf08ca8/dist/elliptic.min.js')}) .then(function () {return loadScript('https://cdn.jsdelivr.net/npm/webcrypto-liner@1.2.2/build/webcrypto-liner.shim.min.js')}) .then(function () {return loadScript('https://cdn.jsdelivr.net/npm/protobufjs@6.8.8/dist/protobuf.min.js')}) .then(function () {return loadScript('https://cdn.jsdelivr.net/npm/@webcrypto-local/client@1.1.0/build/webcrypto-socket.min.js')}) .then(function () {return loadScript('js/main.js')}) .catch(function(e) { document.getElementById('loading').style.display = 'none'; document.getElementById('outdated').style.display = ''; console.error(e); }); }); } </script> </div> </body> </html>
PHP
<?php if (!isset($_GET['action'])) { die(); } // load and register the autoload function require_once __DIR__ . '/../../../../../../bootstrap.php'; $fileToSign = $assetsDirectory . '/pdfs/tektown/Laboratory-Report.pdf'; // for demonstration purpose we use a session for state handling // in a production environment you may use a more reasonable solution session_start(); try { // a simple "controller": switch ($_GET['action']) { // This action expects the certificate of the signer. // It prepares the PDF document accordingly. case 'start': if (isset($_SESSION['tmpDocument'])) { @unlink($_SESSION['tmpDocument']->getWriter()->getPath()); } $data = json_decode(file_get_contents('php://input')); if (!isset($data->certificate)) { throw new Exception('Missing certificate!'); } // load the PDF document $document = \SetaPDF_Core_Document::loadByFilename($fileToSign); // create a signer instance $signer = new \SetaPDF_Signer($document); // create a module instance $module = new \SetaPDF_Signer_Signature_Module_Pades(); $module->setDigest(\SetaPDF_Signer_Digest::SHA_256); // create a certificate instance $certificate = new \SetaPDF_Signer_X509_Certificate($data->certificate); // pass the user certificate to the module $module->setCertificate($certificate); // setup information resolver manager $informationResolverManager = new \SetaPDF_Signer_InformationResolver_Manager(); $informationResolverManager->addResolver(new \SetaPDF_Signer_InformationResolver_HttpCurlResolver([ CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 5 ])); $extraCerts = new \SetaPDF_Signer_X509_Collection(); // get issuer certificates if (isset($data->useAIA) && $data->useAIA) { $certificates = [$certificate]; while (count($certificates) > 0) { /** @var \SetaPDF_Signer_X509_Certificate $currentCertificate */ $currentCertificate = array_pop($certificates); /** @var \SetaPDF_Signer_X509_Extension_AuthorityInformationAccess $aia */ $aia = $currentCertificate->getExtensions()->get(\SetaPDF_Signer_X509_Extension_AuthorityInformationAccess::OID); if ($aia instanceof \SetaPDF_Signer_X509_Extension_AuthorityInformationAccess) { foreach ($aia->fetchIssuers($informationResolverManager)->getAll() as $issuer) { $extraCerts->add($issuer); $certificates[] = $issuer; } } } } $module->setExtraCertificates($extraCerts); $signatureContentLength = 10000; foreach ($extraCerts->getAll() as $extraCert) { $signatureContentLength += (strlen($extraCert->get(\SetaPDF_Signer_X509_Format::DER)) * 2); } $signer->setSignatureContentLength($signatureContentLength); unset($_SESSION['tsUrl']); // get timestamp information and use it if (isset($data->useTimestamp) && $data->useTimestamp) { /** @var \SetaPDF_Signer_X509_Extension_TimeStamp $ts */ $ts = $certificate->getExtensions()->get(\SetaPDF_Signer_X509_Extension_TimeStamp::OID); if ($ts && $ts->getVersion() === 1 && $ts->requiresAuth() === false) { $_SESSION['tsUrl'] = $ts->getLocation(); $signer->setSignatureContentLength($signatureContentLength + 6000); } } // you may use an own temporary file handler $tempPath = \SetaPDF_Core_Writer_TempFile::createTempPath(); // prepare the PDF $tmpDocument = $signer->preSign( new \SetaPDF_Core_Writer_File($tempPath), $module ); // prepare the response $response = [ 'dataToSign' => \SetaPDF_Core_Type_HexString::str2hex( $module->getDataToSign($tmpDocument->getHashFile()) ) ]; $_SESSION['module'] = $module; $_SESSION['tmpDocument'] = $tmpDocument; // send it header('Content-Type: application/json; charset=utf-8'); echo json_encode($response); break; // This action embeddeds the signature in the CMS container // and optionally requests and embeds the time stamp case 'complete': $data = json_decode(file_get_contents('php://input')); if (!isset($data->signature)) { die(); } $data->signature = \SetaPDF_Core_Type_HexString::hex2str($data->signature); // create the document instance $writer = new \SetaPDF_Core_Writer_String(); $document = \SetaPDF_Core_Document::loadByFilename($fileToSign, $writer); $signer = new \SetaPDF_Signer($document); // pass the signature to the signature modul $_SESSION['module']->setSignatureValue($data->signature); // get the CMS structur from the signature module $cms = (string)$_SESSION['module']->getCms(); // verify that the received signature matches to the CMS package and document. $signedData = new \SetaPDF_Signer_Cms_SignedData($cms); $signedData->setDetachedSignedData($_SESSION['tmpDocument']->getHashFile()); if (!$signedData->verify($signedData->getSigningCertificate())) { throw new Exception('Signature cannot be verified!'); } // add the timestamp (if available) if (isset($_SESSION['tsUrl'])) { $tsModule = new \SetaPDF_Signer_Timestamp_Module_Rfc3161_Curl($_SESSION['tsUrl']); $signer->setTimestampModule($tsModule); $cms = $signer->addTimeStamp($cms, $_SESSION['tmpDocument']); } // save the signature to the temporary document $signer->saveSignature($_SESSION['tmpDocument'], $cms); // clean up temporary file unlink($_SESSION['tmpDocument']->getWriter()->getPath()); if (!isset($_SESSION['pdfs']['currentId'])) { $_SESSION['pdfs'] = ['currentId' => 0, 'docs' => []]; } else { // reduce the session data to 5 signed files only while (count($_SESSION['pdfs']['docs']) > 5) { array_shift($_SESSION['pdfs']['docs']); } } $id = $_SESSION['pdfs']['currentId']++; $_SESSION['pdfs']['docs']['id-' . $id] = $writer; // send the response header('Content-Type: application/json; charset=utf-8'); echo json_encode(['id' => $id]); break; // a download action case 'download': $key = 'id-' . (isset($_GET['id']) ? $_GET['id'] : ''); if (!isset($_SESSION['pdfs']['docs'][$key])) { die(); } $doc = $_SESSION['pdfs']['docs'][$key]; header('Content-Type: application/pdf'); header('Content-Disposition: attachment; filename="' . basename($fileToSign, '.pdf') . '-signed.pdf"'); header('Expires: 0'); header('Cache-Control: must-revalidate, post-check=0, pre-check=0'); header('Pragma: public'); header('Content-Length: ' . strlen($doc)); echo $doc; flush(); break; } } catch (\Exception $e) { header('Content-Type: application/json; charset=utf-8', true, 500); echo json_encode(['error' => $e->getMessage()]); }
(function() { // Some helper functions: function show(id, replacements) { replacements = replacements || {}; let e = document.getElementById(id); if (!e.htmlTpl) { e.htmlTpl = e.innerHTML; } if (Object.keys(replacements).length > 0) { let tpl = e.htmlTpl; for (let k in replacements) { if (!replacements.hasOwnProperty(k)) { continue; } let v = replacements[k]; tpl = tpl.replace('{' + k + '}', v); } e.innerHTML = tpl; } e.style.display = ''; } function hide(id) { document.getElementById(id).style.display = 'none'; } // some helper functions to work with typed arrays function toHex(buffer) { let buf = new Uint8Array(buffer), splitter = "", res = [], len = buf.length; for (let i = 0; i < len; i++) { let char = buf[i].toString(16); res.push(char.length === 1 ? "0" + char : char); } return res.join(splitter); } function fromHex(hexString) { let res = new Uint8Array(hexString.length / 2); for (let i = 0; i < hexString.length; i = i + 2) { let c = hexString.slice(i, i + 2); res[i / 2] = parseInt(c, 16); } return res.buffer; } // we need some ajax function postRequest(url, params) { return new Promise(function(resolve, reject) { let xhr = new XMLHttpRequest(); xhr.open("POST", url, true); xhr.onreadystatechange = () => { if (xhr.readyState === 4) { if (xhr.status === 200) { resolve(xhr.responseText); } else { reject(xhr, xhr.status); } } }; xhr.onerror = (() => reject(xhr, xhr.status)); xhr.send(params); }); } // the main function async function main() { let ws = new WebcryptoSocket.SocketProvider({ storage: await WebcryptoSocket.BrowserStorage.create(), }); // Checks if end-to-end session is approved let handleChallenge = async () => { if (!await ws.isLoggedIn()) { const pin = await ws.challenge(); // show PIN show('challengeExchange', {pin: pin}); // ask to approve session try { await ws.login(); } catch (e) { if (confirm('Challenge was not accepted. Retry?')) { await handleChallenge(); } } hide('challengeExchange'); } }; let providersSelect = document.getElementById('providersSelect'), certificatesSelect = document.getElementById('certificatesSelect'), signBtn = document.getElementById('signBtn'), downloadBtn = document.getElementById('downloadBtn'); signBtn.disabled = true; downloadBtn.disabled = true; let init = () => { show('signatureControlsPanel'); ws.cardReader .on("insert", () => updateProviders()) .on("remove", () => updateProviders()); updateProviders(); }; let updateProviders = async () => { const info = await ws.info(); let selected = false; let currentProviderId = providersSelect.value; providersSelect.length = 0; if (!info.providers.length) { const option = document.createElement("option"); option.textContent = "No providers"; option.setAttribute("value", ""); option.disabled = true; providersSelect.appendChild(option); providersSelect.dispatchEvent(new Event('change')); return; } for (const provider of info.providers) { const option = document.createElement("option"); option.setAttribute("value", provider.id); option.textContent = provider.name; if (currentProviderId === provider.id) { option.setAttribute("selected", "selected"); selected = true; } providersSelect.appendChild(option); } if (!selected) { providersSelect.firstElementChild.setAttribute("selected", "selected"); } providersSelect.dispatchEvent(new Event('change')); }; providersSelect.addEventListener('change', () => updateCertificates()); let certs, provider; let updateCertificates = async () => { if (providersSelect.value === '') { certificatesSelect.length = 0; const option = document.createElement("option"); option.textContent = "No certificates"; option.setAttribute("value", ""); option.disabled = true; certificatesSelect.appendChild(option); return; } provider = await ws.getCrypto(providersSelect.value); if (!(await provider.isLoggedIn())) { try { await provider.login(); } catch (e) { // you may map e.code to a more meaningful message. A list of codes is available // here: https://github.com/PeculiarVentures/fortify-web/blob/master/src/sagas/error.js alert(e.message); providersSelect.length = 0; await updateProviders(); return; } } certs = []; let certIds = await provider.certStorage.keys(); certIds = certIds.filter((id) => { const parts = id.split("-"); return parts[0] === "x509"; }); let keyIds = await provider.keyStorage.keys(); keyIds = keyIds.filter((id) => (id.split("-")[0] === "private")); const extractCommonName = (name) => { let reg = /CN=([^,]+),?/i, res = reg.exec(name); return res ? res[1] : "Unknown"; }; for (const certId of certIds) { for (const keyId of keyIds) { if (keyId.split("-")[2] === certId.split("-")[2]) { try { const cert = await provider.certStorage.getItem(certId); certs.push({ id: certId, item: cert, name: extractCommonName(cert.subjectName), pem: await provider.certStorage.exportCert('pem', cert), privateKey: await provider.keyStorage.getItem(keyId) }); } catch (e) { console.error(`Cannot get certificate ${certId} from CertificateStorage. ${e.message}`); } } } } const now = new Date(); certs = certs .filter((cert) => (cert.item.notBefore < now && now < cert.item.notAfter)) .sort((a, b) => (a.name.localeCompare(b.name, undefined, {sensitivity: 'base'}))); certificatesSelect.length = 0; certs.forEach((cert, index) => { const option = document.createElement("option"), issuer = extractCommonName(cert.item.issuerName); option.setAttribute("value", index); option.textContent = cert.name + ' (' + issuer + '; ' + ' not before:' + cert.item.notBefore.toLocaleString() + '; ' + ' not after:' + cert.item.notAfter.toLocaleString() + ')'; certificatesSelect.appendChild(option); }); signBtn.disabled = (certs.length === 0); }; let lastId = null; let sign = async () => { let cert = certs[certificatesSelect.value]; try { let startResponseText = await postRequest( 'controller.php?action=start', JSON.stringify({ certificate: cert.pem, useAIA: document.getElementById('useAIA').checked, useTimestamp: document.getElementById('useTimestamp').checked }) ); let startJson = JSON.parse(startResponseText), privateKey = cert.privateKey; const message = fromHex(startJson.dataToSign); const alg = { name: privateKey.algorithm.name, hash: "SHA-256", }; let signature = await provider.subtle.sign(alg, privateKey, message); let completeResponseText = await postRequest( 'controller.php?action=complete', JSON.stringify({signature: toHex(signature)}) ); let completeJson = JSON.parse(completeResponseText); lastId = completeJson.id; downloadBtn.disabled = false; window.open('controller.php?action=download&id=' + lastId); } catch (error) { console.info(error); alert('An error occured: ' + error.responseText); } }; downloadBtn.addEventListener('click', () => window.open('controller.php?action=download&id=' + lastId)); signBtn.addEventListener('click', () => sign()); ws.connect("127.0.0.1:31337") .on("error", (e) => { hide('loading'); show('fortifyNotReachable'); console.error(e); }) .on("listening", (e) => { hide('loading'); handleChallenge() .then(() => { return ws.isLoggedIn(); }) .then((isLoggedIn) => { // was it successfully? if (!isLoggedIn) { show('notLoggedIn'); return; } init(); }, (error) => { console.error(error) }); }); } //noinspection JSIgnoredPromiseFromCall main(); })();