Fortify With Vanilla JavaScript
This is a SetaPDF-Signer 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 AIA extension (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 use setasign\SetaPDF2\Core\Document; use setasign\SetaPDF2\Core\Type\PdfHexString; use setasign\SetaPDF2\Core\Writer\FileWriter; use setasign\SetaPDF2\Core\Writer\StringWriter; use setasign\SetaPDF2\Core\Writer\TempFileWriter; use setasign\SetaPDF2\Signer\Asn1\Element as Asn1Element; use setasign\SetaPDF2\Signer\Cms\SignedData; use setasign\SetaPDF2\Signer\Digest; use setasign\SetaPDF2\Signer\InformationResolver\HttpCurlResolver; use setasign\SetaPDF2\Signer\InformationResolver\Manager as InformationResolverManager; use setasign\SetaPDF2\Signer\Signature\Module\Pades as PadesModule; use setasign\SetaPDF2\Signer\Signer; use setasign\SetaPDF2\Signer\Timestamp\Module\Rfc3161\Curl as CurlTimestampModule; use setasign\SetaPDF2\Signer\X509\Certificate; use setasign\SetaPDF2\Signer\X509\Collection; use setasign\SetaPDF2\Signer\X509\Extension\AuthorityInformationAccess; use setasign\SetaPDF2\Signer\X509\Extension\TimeStamp as TimeStampExtension; use setasign\SetaPDF2\Signer\X509\Format; 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 = Document::loadByFilename($fileToSign); // create a signer instance $signer = new Signer($document); // create a module instance $module = new PadesModule(); $module->setDigest(Digest::SHA_256); // create a certificate instance $certificate = new Certificate($data->certificate); // pass the user certificate to the module $module->setCertificate($certificate); // setup information resolver manager $informationResolverManager = new InformationResolverManager(); $informationResolverManager->addResolver(new HttpCurlResolver([ \CURLOPT_FOLLOWLOCATION => true, \CURLOPT_MAXREDIRS => 5 ])); $extraCerts = new Collection(); // get issuer certificates if (isset($data->useAIA) && $data->useAIA) { $certificates = [$certificate]; while (count($certificates) > 0) { /** @var Certificate $currentCertificate */ $currentCertificate = array_pop($certificates); /** @var AuthorityInformationAccess $aia */ $aia = $currentCertificate->getExtensions()->get(AuthorityInformationAccess::OID); if ($aia instanceof 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(Format::DER)) * 2); } $signer->setSignatureContentLength($signatureContentLength); unset($_SESSION['tsUrl']); // get timestamp information and use it if (isset($data->useTimestamp) && $data->useTimestamp) { /** @var TimeStampExtension $ts */ $ts = $certificate->getExtensions()->get(TimeStampExtension::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 = TempFileWriter::createTempPath(); // prepare the PDF $tmpDocument = $signer->preSign( new FileWriter($tempPath), $module ); // prepare the response $response = [ 'dataToSign' => PdfHexString::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 embeds 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 = PdfHexString::hex2str($data->signature); // create the document instance $writer = new StringWriter(); $document = Document::loadByFilename($fileToSign, $writer); $signer = new Signer($document); if (isset($data->algorithmName) && $data->algorithmName === 'ECDSA') { $len = \strlen($data->signature); // prohibit 'Uninitialized string offset 0' error if ($len < 2) { throw new Exception('Signature value is faulty!'); } $s = \substr($data->signature, 0, $len / 2); if (\ord($s[0]) & 0x80) { // ensure positive integers $s = "\0" . $s; } $r = \substr($data->signature, $len / 2); if (\ord($r[0]) & 0x80) { // ensure positive integers $r = "\0" . $r; } $signatureValueElm = new Asn1Element( Asn1Element::SEQUENCE | Asn1Element::IS_CONSTRUCTED, '', [ new Asn1Element(Asn1Element::INTEGER, $s), new Asn1Element(Asn1Element::INTEGER, $r), ] ); $data->signature = (string)$signatureValueElm; } // pass the signature to the signature modul $_SESSION['module']->setSignatureValue($data->signature); // get the CMS structure from the signature module $cms = (string)$_SESSION['module']->getCms(); // verify that the received signature matches to the CMS package and document. $signedData = new 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 CurlTimestampModule($_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-' . ($_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 (\Throwable $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,
algorithmName = privateKey.algorithm.name;
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), algorithmName: algorithmName})
);
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();
})();