Fortify Web-component Batch Signatures
This demo shows an implementation with the web-component of Fortify for signing multiple documents at once.
PHP
<?php if ($_SERVER['SERVER_NAME'] !== 'localhost' && (empty($_SERVER['HTTPS']) || $_SERVER['HTTPS'] === 'off')) { throw new Exception('This demo must run on localhost or HTTPS.'); } $path = substr($_SERVER['PHP_SELF'], 0, -strlen(basename(__FILE__))); $controllerPath = 'https://' . $_SERVER['HTTP_HOST'] . $path . 'controller.php'; ?> <!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> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pure-css-loader@3.3.3/dist/css-loader.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@peculiar/fortify-webcomponents@4/dist/peculiar/peculiar.css"> <style> * { box-sizing: border-box; } body { font-family: "Open Sans", "Arial", sans-serif; font-size: 14px; height: 100vh; color: rgb(64, 72, 79); margin: 0; padding: 0; /* adjust some colors in the Fortify webcomponent */ --peculiar-color-footer-rgb: 255, 255, 255; --peculiar-color-footer-text-rgb: 0, 0, 0; } #signatureControlsPanel, #previewContainer, #signButtonContainer, #fortifyContainer, #downloadButtonContainer { height:100%; } #previewContainer, #signButtonContainer, #fortifyContainer, #downloadButtonContainer { border: 0; float: left; width: 50%; } #signButtonContainer, #fortifyContainer, #downloadButtonContainer { border-right: 1px solid rgb(234, 237, 242); border-top: 1px solid rgb(234, 237, 242); border-bottom: 1px solid rgb(234, 237, 242); } #signButtonContainer, #downloadButtonContainer { padding: 34px 50px 46px; } h4 { font-size: 17px; margin-top: 0; padding-top: 0; } button.btnContinue, button.btnCancel { justify-content: center; border-radius: 3px; padding: 0 26px; height: 40px; float: right; cursor: pointer; transition: color 200ms; } button.btnContinue { color: #ffffff; border: 1px solid rgb(10, 190, 101); background-color: rgb(10, 190, 101); } button.btnContinue:hover { color: #9ddd97; } button.btnCancel { color: rgb(109, 125, 135); background-color: #ffffff; border: 1px solid rgb(182, 195, 204); float: none; } button.btnCancel:hover { color: rgb(182, 195, 204); } button.btnContinue:focus, button.btnCancel:focus { box-shadow: 0 4px 10px 0 rgba(0, 0, 0, 0.1); outline:none; } label.checkbox { display: block; margin: 5px 0; margin-left: 20px; } label.checkbox input[type=checkbox] { position: absolute; margin-left: -20px; } .loader-default::after { border-color: rgb(13, 132, 255); border-left-color: transparent; } </style> </head> <body> <div id="loader" class="loader loader-default is-active" data-text="Loading..."></div> <div id="outdated" style="display:none;">Your browser is outdated.</div> <div id="signatureControlsPanel" style="display:none;"> <div id="previewContainer"></div> <div id="signButtonContainer"> <h4>Signature Settings</h4> <label class="checkbox" for="useAIA"> <input type="checkbox" name="useAIA" id="useAIA" checked="checked"/> Embedded certificates fetched from the <a href="http://www.pkiglobe.org/auth_info_access.html" target="_blank">AIA extension</a>. </label> <label class="checkbox" for="useTimestamp"> <input type="checkbox" name="useTimestamp" id="useTimestamp" checked="checked" /> Embedded timestamp if adobe timestamp extension is available in certificate. </label> <button id="signBtn" class="btnContinue">Start and choose certificate</button> </div> <div id="fortifyContainer" style="display: none;height:100%;"></div> <div id="downloadButtonContainer" style="display: none;"> <p>The documents were successfully signed.</p> <p id="extraCerts"></p> <p id="tsUrl"></p> <button id="resetBtn" class="btnCancel">Restart</button> </div> </div> <script type="text/javascript"> var controllerPath = '<?=$controllerPath?>'; document.addEventListener("DOMContentLoaded", function () { function loadScript(src, module) { 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; if (typeof module !== 'undefined') { if (module) { script.type = 'module'; } else { script.noModule = true; } } document.head.appendChild(script); }); } try { loadScript('https://cdn.jsdelivr.net/npm/@peculiar/fortify-webcomponents@4/dist/peculiar/peculiar.esm.js', true) .then(function () {return loadScript('https://verify.ink/webcomponent/index.js', true)}) .then(function () {return loadScript('js/main.js')}) .catch(function (e) { document.getElementById('loader').style.display = 'none'; document.getElementById('outdated').style.display = ''; console.error(e); }); } catch (e) { document.getElementById('loader').style.display = 'none'; document.getElementById('outdated').style.display = ''; console.error(e); } }); </script> </body> </html>
PHP
<?php if (!isset($_GET['action'])) { die(); } // load and register the autoload function require_once __DIR__ . '/../../../../../../bootstrap.php'; $filesToSign = [ 'tektown' => $assetsDirectory . '/pdfs/tektown/Laboratory-Report.pdf', 'camtown' => $assetsDirectory . '/pdfs/camtown/Laboratory-Report.pdf', 'lenstown' => $assetsDirectory . '/pdfs/lenstown/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']) { case 'preview': if (!array_key_exists($_GET['file'], $filesToSign)) { http_response_code(404); die(); } $doc = file_get_contents($filesToSign[$_GET['file']]); // Note: these lines are only required for the Verify.ink pdf viewer because of CORS header('Access-Control-Allow-Origin: https://verify.ink'); header('Access-Control-Allow-Credentials: true'); header('Access-Control-Expose-Headers: Content-Disposition'); header('Content-Type: application/pdf'); header('Content-Disposition: attachment; filename="' . $_GET['file'] . '.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; // This action expects the certificate of the signer. // It prepares the PDF document accordingly. case 'start': if (isset($_SESSION['tmpDocuments'])) { foreach ($_SESSION['tmpDocuments'] as $tmpDocument) { @unlink($tmpDocument['tmpDocument']->getWriter()->getPath()); } } $data = json_decode(file_get_contents('php://input')); if (!isset($data->certificate)) { throw new Exception('Missing certificate!'); } // create a certificate instance $certificate = new \SetaPDF_Signer_X509_Certificate($data->certificate); $extraCerts = new \SetaPDF_Signer_X509_Collection(); // setup information resolver manager $informationResolverManager = new \SetaPDF_Signer_InformationResolver_Manager(); $informationResolverManager->addResolver(new \SetaPDF_Signer_InformationResolver_HttpCurlResolver([ CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 5 ])); // 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; } } } } $signatureContentLength = 10000; foreach ($extraCerts->getAll() as $extraCert) { $signatureContentLength += (strlen($extraCert->get(\SetaPDF_Signer_X509_Format::DER)) * 2); } 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(); $signatureContentLength += 6000; } } $tmpDocuments = []; foreach ($filesToSign as $k => $fileToSign) { // 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(); // pass the user certificate to the module $module->setCertificate(clone $certificate); $module->setExtraCertificates(clone $extraCerts); $signer->setSignatureContentLength($signatureContentLength); // A simple example to add a visible signature. // $field = $signer->addSignatureField( // 'Signature', 1, \SetaPDF_Signer_SignatureField::POSITION_LEFT_TOP, ['x' => 20, 'y' => -20], 180, 60 // ); // $signer->setSignatureFieldName($field->getQualifiedName()); // // $appearance = new \SetaPDF_Signer_Signature_Appearance_Dynamic($module); // $signer->setAppearance($appearance); // you may use an own temporary file handler $tempPath = \SetaPDF_Core_Writer_TempFile::createTempPath(); $tmpDocuments[$k] = [ 'tmpDocument' => $signer->preSign( new \SetaPDF_Core_Writer_File($tempPath), $module ), 'module' => $module ]; } // prepare the response $response = [ 'dataToSign' => array_map(function ($tmpDocument) { return \SetaPDF_Core_Type_HexString::str2hex( $tmpDocument['module']->getDataToSign($tmpDocument['tmpDocument']->getHashFile()) ); }, $tmpDocuments), 'extraCerts' => array_map(function (\SetaPDF_Signer_X509_Certificate $cert) { return $cert->get(\SetaPDF_Signer_X509_Format::PEM); }, $extraCerts->getAll()), 'tsUrl' => isset($_SESSION['tsUrl']) ? $_SESSION['tsUrl'] : false ]; $_SESSION['tmpDocuments'] = $tmpDocuments; // 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 timestamp case 'complete': $data = json_decode(file_get_contents('php://input'), true); if (!isset($data['signatures'])) { die(); } $data['signatures'] = array_map([\SetaPDF_Core_Type_HexString::class, 'hex2str'], $data['signatures']); $resultIds = []; foreach ($filesToSign as $key => $fileToSign) { // 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['tmpDocuments'][$key]['module']->setSignatureValue($data['signatures'][$key]); // get the CMS structur from the signature module $cms = (string)$_SESSION['tmpDocuments'][$key]['module']->getCms(); // verify that the received signature matches to the CMS package and document. $signedData = new \SetaPDF_Signer_Cms_SignedData($cms); $signedData->setDetachedSignedData($_SESSION['tmpDocuments'][$key]['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['tmpDocuments'][$key]['tmpDocument']); } // save the signature to the temporary document $signer->saveSignature($_SESSION['tmpDocuments'][$key]['tmpDocument'], $cms); // clean up temporary file unlink($_SESSION['tmpDocuments'][$key]['tmpDocument']->getWriter()->getPath()); if (!isset($_SESSION['pdfs']['currentId'])) { $_SESSION['pdfs'] = ['currentId' => 0, 'docs' => []]; } else { // reduce the session data to 6 signed files only while (count($_SESSION['pdfs']['docs']) > 6) { array_shift($_SESSION['pdfs']['docs']); } } $id = $_SESSION['pdfs']['currentId']++; $_SESSION['pdfs']['docs']['id-' . $id] = $writer; $resultIds[$key] = $id; } // send the response header('Content-Type: application/json; charset=utf-8'); echo json_encode([ 'ids' => $resultIds ]); 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]; // Note: these lines are only required for the Verify.ink pdf viewer because of CORS header('Access-Control-Allow-Origin: https://verify.ink'); header('Access-Control-Allow-Credentials: true'); header('Access-Control-Expose-Headers: Content-Disposition'); header('Content-Type: application/pdf'); header('Content-Disposition: attachment; filename="' . (isset($_GET['name']) ? $_GET['name'] : 'document') . '-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) { document.getElementById(id).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 lastId = null, fortifyComp = null; function initVerify(urls) { document.getElementById('previewContainer').innerHTML = '<verify-viewer' + ' url="' + urls.join(',') + '"' + ' show-signature-if-present="true"' + ' notify-if-not-signed="false"' + ' sign="false"' + ' search="false"' + ' download="true"' + ' style="height: 100%;"' + ' ></verify-viewer>'; } function initPreviewFiles() { initVerify([ controllerPath + '?action=preview&file=tektown', controllerPath + '?action=preview&file=camtown', controllerPath + '?action=preview&file=lenstown' ]); } function initCompletedFiles(ids) { let files = []; for (let filename in ids) { if (!ids.hasOwnProperty(filename)) { continue; } files.push(controllerPath + '?action=download&id=' + ids[filename] + '&name=' + filename); } initVerify(files); } function initFortify () { // https://fortifyapp.com/developers/examples/certificate-management fortifyComp = document.createElement('peculiar-fortify-certificates'); fortifyComp.style.height = '100%'; fortifyComp.language = 'en'; fortifyComp.filters = { // onlySmartcards: false, expired: false, // subjectDNMatch: 'apple', // subjectDNMatch: new RegExp(/apple/), // issuerDNMatch: 'demo', // issuerDNMatch: new RegExp(/demo/), // keyUsage: ['digitalSignature'], onlyWithPrivateKey: true, ca: true }; fortifyComp.addEventListener('selectionCancel', function () { hide('fortifyContainer'); show('signButtonContainer'); }); fortifyComp.addEventListener('selectionSuccess', async function (event) { let signatures = {}; try { show('loader'); document.getElementById('loader').setAttribute('data-text', 'Signing document'); let provider = await event.detail.socketProvider.getCrypto(event.detail.providerId); let cert = await provider.certStorage.getItem(event.detail.certificateId); let certPem = await provider.certStorage.exportCert('pem', cert); let privateKey = await provider.keyStorage.getItem(event.detail.privateKeyId); let startResponseText = await postRequest( controllerPath + '?action=start', JSON.stringify({ certificate: certPem, useAIA: document.getElementById('useAIA').checked, useTimestamp: document.getElementById('useTimestamp').checked }) ); let startJson = JSON.parse(startResponseText); if (startJson.extraCerts.length > 0) { document.getElementById('extraCerts').innerHTML = startJson.extraCerts.length + ' extra certificate(s) resolved and embedded through the ' + '<a href="http://www.pkiglobe.org/auth_info_access.html" target="_blank">AIA extension</a>.'; } else { document.getElementById('extraCerts').innerHTML = 'No extra certificates were resolved.'; } if (startJson.tsUrl) { document.getElementById('tsUrl').innerHTML = 'Timestamp server located at <i>' + startJson.tsUrl + '</i> was used.'; } else { document.getElementById('tsUrl').innerHTML = 'No timestamp server found.'; } for (const key in startJson.dataToSign) { if (!startJson.dataToSign.hasOwnProperty(key)) { continue; } const message = fromHex(startJson.dataToSign[key]); const alg = { name: privateKey.algorithm.name, hash: "SHA-256", }; let signature = await provider.subtle.sign(alg, privateKey, message); signatures[key] = toHex(signature); } } catch (error) { hide('loader'); console.info(error); alert('An error occured: ' + error); return; } try { let completeResponseText = await postRequest( controllerPath + '?action=complete', JSON.stringify({signatures}) ); let completeJson = JSON.parse(completeResponseText); initCompletedFiles(completeJson.ids) hide('fortifyContainer'); hide('loader'); show('downloadButtonContainer'); } catch (error) { hide('loader'); console.info(error); alert('An error occured: ' + error.responseText); } }); document.getElementById('fortifyContainer').appendChild(fortifyComp); } document.getElementById('signBtn').addEventListener('click', () => { if (!fortifyComp) { initFortify(); } hide('signButtonContainer'); show('fortifyContainer'); }); document.getElementById('resetBtn').addEventListener('click', () => { hide('downloadButtonContainer'); initPreviewFiles(); show('signButtonContainer'); }); initPreviewFiles(); show('signatureControlsPanel'); hide('loader'); } //noinspection JSIgnoredPromiseFromCall main(); })();