diff --git a/README.md b/README.md index 8d41815..2507b2e 100644 --- a/README.md +++ b/README.md @@ -57,18 +57,43 @@ Install the extension and add the pattern and pubkey shown in the page from the ## As a developer -You need to add a comment at the top of the html file (right after the doctype if exists) that contains the detached PGP signature of the content of the `` tag after it has been minified with [minimized](https://github.com/Swaagie/minimize) with a specific set of settings. +The signatures are specified in html `` tags with the name `signature` within the document ``. There are two types of signatures available: one over the whole document, and one over a minimized version. The signature over the minimized version helps with cross compatiblility accross different browsers. The whole document signatures may be more secure, but only works with browsers that support `filteredResponseData` (Currently only Firefox). Both types of signatures can be included in a document. + +The `content` of the `signature` `` tag except for the signature itself. There can be no whitespace between the `signature=` and the next item or end of tag. + +Example: + ``` + +``` + +For the whole document signature (type `pgp`), all you need to do is sign the document and include the signature in the meta tag. + +For the minimized signature (type `pgp_minimized`), you will need to minimize with [minimize](https://github.com/Swaagie/minimize), version 2.1.0, with a specific set of settings. You will then sign this minimized version and include the signature in the original document `` tag -As you can see, it's a bit involved, so we created a script that does all of this for you. All you need to do is make sure you have a comment at the top of the file that contains the special replace tag like in [example.html](example.html). +As you can see, it's a bit involved, so we created a script that does all of this for you. All you need to do is make sure you include specific placeholders as in [example.html](example.html). And then just run, on a secure machine, preferably with a PGP key on a separate hardware token: ``` -# Print the signed page to stdout -$ ./page-signer.js input.html +# find key id for signing key +$ gpg --list-keys + +# Print the signed page to stdout (You will have a different keyid) +$ ./page-signer.js 9C43B88E input.html # Print the signed page to a file (can be the same as the input file) -$ ./page-signer.js input.html output.html +$ ./page-signer.js 9C43B88E input.html output.html ``` It's important that all of the external resources to the page (JS and CSS, whether hosted on the same server, or not) will have [subresource integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) correctly set. This way you only need to sign the html page, and the rest will be automatically validated by the browser, ensuring that all of the scripts and styles used in the page are indeed what you expect. @@ -116,7 +141,13 @@ What makes matters even worse is that browsers don't return the html as delivere Be aware that the minifier may have bugs that can cause a page to pass verification while being different! Unlikely, but possible, so watch out for minifier bugs. -Since the same signature needs to work on all browsers, we unfortunately have to minimise the html on Firefox too. This workaround will be removed once the aforementioned `filterResponseData` is implemented across browsers. +# Versions + +All signature types must have a version and they must match the supported versions within this extension. A version matches if the major and minor numbers match, and the patch level on the page is less than or equal to the supported patch level. This allows backwards-compatible patches without invalidating previous signatures. Also, when there is new version, the older version will be supported for time. + +Current Supported Versions: +- pgp: 1.0.0 +- pgp_minimized: 1.0.0 # Potential attacks diff --git a/example.html b/example.html index cc7987e..2b571d1 100644 --- a/example.html +++ b/example.html @@ -1,10 +1,25 @@ - + + + + A signed page example! diff --git a/extension/manifest.json b/extension/manifest.json index e461352..c4ad025 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -3,7 +3,7 @@ "manifest_version": 2, "name": "Signed Pages", "description": "Verifies PGP signed pages for extra security against malicious or breached servers.", - "version": "0.4.0", + "version": "0.5.0", "homepage_url": "https://github.com/tasn/webext-signed-pages", "icons": { "48": "images/icon.png", diff --git a/package.json b/package.json index de45b6f..b68dcc9 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "signed-pages", "description": "Verifies PGP signed pages for extra security against malicious or breanched servers.", - "version": "0.4.0", + "version": "0.5.0", "main": "index.js", "scripts": { - "build": "webpack -w --display-error-details --progress --colors", + "build": "npm-install-version minimize@2.1.0 && webpack -w --display-error-details --progress --colors", "package": "webpack --colors && web-ext build -s extension", "start": "web-ext run -s extension/" }, @@ -15,13 +15,13 @@ "babel-loader": "^7.1.2", "babel-preset-react": "^6.24.1", "babel-runtime": "^6.26.0", + "npm-install-version": "^6.0.2", "react": "^16.2.0", "react-dom": "^16.2.0", "web-ext": "^2.2.2", "webpack": "^3.10.0" }, "dependencies": { - "minimize": "^2.1.0", "openpgp": "^2.6.1", "webextension-polyfill": "^0.2.1" }, diff --git a/page-signer.js b/page-signer.js index f131959..0ed7c0f 100755 --- a/page-signer.js +++ b/page-signer.js @@ -1,19 +1,20 @@ #!/usr/bin/env node -/* eslint node: true */ +/* eslint-env node */ +/* eslint-disable no-console */ const fs = require('fs'); const child_process = require('child_process'); -const Minimize = require('minimize'); +const Minimize = require('minimize@2.1.0'); function errorAbort(text) { console.error(text); process.exit(1); } -function getSignature(content, callback) { +function getSignature(content, keyid, callback) { const tmpfile = `/tmp/${process.pid}`; fs.writeFileSync(tmpfile, content, 'utf-8'); - const gpg = child_process.spawnSync('gpg', ['--armor', '--output', '-', '--detach-sign', tmpfile], { + const gpg = child_process.spawnSync('gpg', ['--armor', '--output', '-', '--local-user', keyid, '--detach-sign', tmpfile], { stdio: [ 0, 'pipe', @@ -21,17 +22,33 @@ function getSignature(content, callback) { }); fs.unlink(tmpfile, () => {}); + const signature = gpg.stdout.toString(); + if (callback) + callback(signature); + return signature; +} - callback(gpg.stdout.toString()); +function getPublicKey( keyid, callback) { + const gpg = child_process.spawnSync('gpg', ['--armor', '--output', '-', '--export', keyid], { + stdio: [ + 0, + 'pipe', + ] + }); + const key = gpg.stdout.toString(); + if (callback) + callback(key); + return key; } let args = process.argv.slice(2); +const keyid = args.shift(); const filename = args.shift(); const outfile = args.shift(); -if (!filename) { - errorAbort(`Usage: ${process.argv[1]} [outfile]`); +if (!filename || !keyid) { + errorAbort(`Usage: ${process.argv[1]} [outfile]`); } fs.readFile(filename, 'utf8', (err, data) => { @@ -39,21 +56,35 @@ fs.readFile(filename, 'utf8', (err, data) => { errorAbort(err); } + const key = getPublicKey(keyid); + + // replace public keys + var out = data.replace('%%%SIGNED_PAGES_PUBLICKEY%%%', key); + + // Strip placeholders (and whitespace around them) + const signed_content = out.replace(/\s*%%%SIGNED_PAGES_PGP_SIGNATURE\w*%%%\s*/g, ''); + + console.log(signed_content); + + // Signature of entire document (like pulled from filterResponseData) + var signature = getSignature(signed_content, keyid); + out = out.replace('%%%SIGNED_PAGES_PGP_SIGNATURE%%%', signature); + + // Signature using minimize // Minimize and strip the doctype - const content = new Minimize({ spare:true, conditionals: true, empty: true, quotes: true }).parse(data) + const min_content = new Minimize({ spare:true, conditionals: true, empty: true, quotes: true }).parse(signed_content) .replace(/^\s*]*>/i, ''); - getSignature(content, (signature) => { - const out = data.replace('%%%SIGNED_PAGES_PGP_SIGNATURE%%%', signature); - - if (outfile) { - fs.writeFile(outfile, out, 'utf8', (writeErr) => { - if (writeErr) { - errorAbort(writeErr); - } - }); - } else { - process.stdout.write(out); - } - }); + signature = getSignature(min_content, keyid); + out = out.replace('%%%SIGNED_PAGES_PGP_SIGNATURE_MIN%%%', signature); + + if (outfile) { + fs.writeFile(outfile, out, 'utf8', (writeErr) => { + if (writeErr) { + errorAbort(writeErr); + } + }); + } else { + process.stdout.write(out); + } }); diff --git a/src/background.js b/src/background.js index d45c569..05e5618 100644 --- a/src/background.js +++ b/src/background.js @@ -5,8 +5,6 @@ import { matchPatternToRegExp } from 'match-pattern'; import * as openpgp from 'openpgp'; -import Minimize from 'minimize'; - import defaultItems from 'default-items'; function regex(pattern, input) { @@ -76,27 +74,159 @@ function getPubkey(pubkeyPatterns, url) { // Cache the result status in case we are not doing web requests. let statusCache = {}; -function processPage(rawContent, signature, url, tabId) { +// Versioned minimize +function minimize_1_0(rawContent) { + const Minimize = require('minimize@2.1.0'); const content = new Minimize({ spare:true, conditionals: true, empty: true, quotes: true }).parse(rawContent) - .replace(/^\s*]*>/i, ''); + .replace(/^\s*]*>/i, ''); + return content; +} + +// Version match. +// returns true if major and minor are equal and patch less than or equal to taget +function matchVersions(version, target) { + let v = version.split(".").map(i => parseInt(i,10)); + let t = target.split(".").map(i => parseInt(i,10)); + if (v.length != 3) + return false; + return (v[0] == t[0]) && (v[1] == t[1]) && (v[2] <= t[2]); +} + +function defaultOptions() { + return { + signatures: [], + trustedPublicKeys: [], + trustedDNS: false, + }; +} + +function defaultSignature() { + return { + allowedmethods:['filteredrequestdata', 'outsidehtml'] + } +} + +function parseOptions(content) { + let options = defaultOptions(); + const head = content.split(/<\/head\s*>/i)[0]; + + // Parse signature metadata + const signatureRegex = RegExp('', 'gi'); + const nameRegex = RegExp('\\s*([\\w-]+)=([^,"]*),?', 'gi'); + let sigMatch; + while( (sigMatch = signatureRegex.exec(head)) !== null ) { + let signature = defaultSignature(); + let nameMatch; + while ( (nameMatch = nameRegex.exec(sigMatch[1])) !== null ) { + const name = nameMatch[1].toLowerCase(); + if (name == 'signature') { + // preserve whitespace to ensure all of it is stripped out + signature[name] = nameMatch[2]; + } else if (name == 'allowedmethods') { + // split on spaces + signature[name] = nameMatch[2].split(" ").map(s => s.trim().toLowerCase()); + } else { + signature[name] = nameMatch[2].trim().toLowerCase(); + } + } + if (signature.type && signature.version && signature.signature) + options.signatures.push(signature); + } + + return options; +} + +// Strip all signatures from content for signature verification +function stripSignatures(content, options) { + let newContent = content; + for (let signature of options.signatures) { + newContent = newContent.replace(signature.signature, ""); + signature.signature = signature.signature.trim(); + } + return newContent; +} - const shouldCheck = getPubkey(patterns, url); +function validateSignature(content, signature, pageOptions, pubkey) { + const options = { + message: openpgp.message.fromBinary(openpgp.util.str2Uint8Array(content)), + signature: openpgp.signature.readArmored(signature), + publicKeys: openpgp.key.readArmored(pubkey).keys, + }; - if (shouldCheck) { - try { - const pubkey = patterns[shouldCheck]; + return openpgp.verify(options).then((verified) => { + return verified.signatures[0].valid; + }); +} + +// Returns when first promise returns true +function promise_any(promises) { + return new Promise((resolve,reject) => { + Promise.all(promises.map(promise => promise.then(value => { + if (value) + resolve(value); + return value; + }).catch( error => { + reject(error); + }))).then(values => { + if (values.every(x => x == false)) + resolve(false) + }) + }) +} - const options = { - message: openpgp.message.fromBinary(openpgp.util.str2Uint8Array(content)), - signature: openpgp.signature.readArmored(signature), - publicKeys: openpgp.key.readArmored(pubkey).keys, - }; +function validateSignatures(content, options, pubKey, method) { + return promise_any(options.signatures.map(signature => { + if (signature.allowedmethods.includes(method.toLowerCase())) { + if (signature.type == 'pgp') { + return validateSignature(content, signature.signature, options, pubKey); + } else if (signature.type == 'pgpMinimized') { + if (matchVersions(signature.version, '1.0.0')) { + const signedContent = minimize_1_0(content); + return validateSignature(signedContent, signature.signature, options, pubKey); + } + } + } + return Promise.resolve(false); + })) +} - openpgp.verify(options).then((verified) => { - const signatureData = (verified.signatures[0].valid) ? goodSignature : badSignature; - updateBrowserAction(signatureData, tabId); - statusCache[url] = signatureData; - }); +function processPage(rawContent, legacySignature, url, tabId, method) { + const pattern = getPubkey(patterns, url); + if (pattern) { + // only test if the user defined a pattern + try { + const pubkey = patterns[pattern].trim(); + let options, content; + if (legacySignature) { + // Legacy signature + options = defaultOptions(); + options.signatures = [{ + type: 'pgp_minimized', + version: '1.0.0', + signature: legacySignature, + allowedmethods: ['filterrequestmetadata', 'outsidehtml'] + }]; + // ?? Do we need to strip signature? Old code relied on minimizer + content = rawContent; + } else { + options = parseOptions(rawContent); + content = stripSignatures(rawContent, options); + } + if (options !== null) { + validateSignatures(content, options, pubkey, method) + .then((verified) => { + const signatureData = (verified) ? goodSignature : badSignature; + updateBrowserAction(signatureData, tabId); + statusCache[url] = signatureData; + }) + .catch(() => { + updateBrowserAction(badSignature, tabId); + statusCache[url] = badSignature; + }); + } else { + updateBrowserAction(badSignature, tabId); + statusCache[url] = badSignature; + } } catch (e) { updateBrowserAction(badSignature, tabId); statusCache[url] = badSignature; @@ -106,9 +236,10 @@ function processPage(rawContent, signature, url, tabId) { } } +// extract legacy signature function extractSignature(str) { - const signatureMatch = /-----BEGIN PGP SIGNATURE-----[^-]*-----END PGP SIGNATURE-----/g.exec(str); - return signatureMatch ? signatureMatch[0] : undefined; + const signatureMatch = /