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
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\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);
// 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;
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();
})();