diff --git a/__tests__/plugins/signed-integrity.test.js b/__tests__/plugins/signed-integrity.test.js new file mode 100644 index 0000000..c053780 --- /dev/null +++ b/__tests__/plugins/signed-integrity.test.js @@ -0,0 +1,161 @@ +describe("plugin: signed-integrity", () => { + + beforeEach(() => { + global.nodeFetch = require('node-fetch') + global.Request = global.nodeFetch.Request + global.Response = global.nodeFetch.Response + global.crypto = require('crypto').webcrypto + global.Blob = require('buffer').Blob; + jest.resetModules(); + self = global + global.btoa = (bin) => { + return Buffer.from(bin, 'binary').toString('base64') + } + + global.LibResilientPluginConstructors = new Map() + LR = { + log: (component, ...items)=>{ + console.debug(component + ' :: ', ...items) + } + } + + global.resolvingFetch = jest.fn((url, init)=>{ + var content = '{"test": "success"}' + var status = 200 + var statusText = "OK" + if (url == 'https://resilient.is/test.json.integrity') { + content = '{"integrity": "sha256-eiMrFuthzteJuj8fPwUMyNQMb2SMW7VITmmt2oAxGj0="}' + } else if (url == 'https://resilient.is/fail.json.integrity') { + content = '{"test": "fail"}' + status = 404 + statusText = "Not Found" + } + return Promise.resolve( + new Response( + [content], + { + type: "application/json", + status: status, + statusText: statusText, + headers: { + 'ETag': 'TestingETagHeader' + }, + url: url + } + ) + ) + }) + + init = { + name: 'signed-integrity', + uses: [ + { + name: 'resolve-all', + description: 'Resolves all', + version: '0.0.1', + fetch: resolvingFetch + } + ], + requireIntegrity: false + } + requestInit = { + integrity: "sha256-eiMrFuthzteJuj8fPwUMyNQMb2SMW7VITmmt2oAxGj0=" + } + self.log = function(component, ...items) { + console.debug(component + ' :: ', ...items) + } + }) + + test("it should register in LibResilientPluginConstructors", () => { + require("../../plugins/signed-integrity.js"); + expect(LibResilientPluginConstructors.get('signed-integrity')(LR, init).name).toEqual('signed-integrity'); + }); + + test("it should throw an error when there aren't any wrapped plugins configured", async () => { + require("../../plugins/signed-integrity.js"); + init = { + name: 'signed-integrity', + uses: [] + } + + expect.assertions(2); + try { + await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/test.json') + } catch (e) { + expect(e).toBeInstanceOf(Error) + expect(e.toString()).toMatch('Expected exactly one plugin to wrap') + } + }); + + test("it should throw an error when there are more than one wrapped plugins configured", async () => { + require("../../plugins/signed-integrity.js"); + init = { + name: 'signed-integrity', + uses: [{ + name: 'plugin-1' + },{ + name: 'plugin-2' + }] + } + + expect.assertions(2); + try { + await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/test.json') + } catch (e) { + expect(e).toBeInstanceOf(Error) + expect(e.toString()).toMatch('Expected exactly one plugin to wrap') + } + }); + + test("it should fetch content when integrity data provided without trying to fetch the integrity data URL", async () => { + require("../../plugins/signed-integrity.js"); + + const response = await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/test.json', { + integrity: "sha384-x4iqiH3PIPD51TibGEhTju/WhidcIEcnrpdklYEtIS87f96c4nLyj6CuwUp8kyOo" + }); + + expect(resolvingFetch).toHaveBeenCalledTimes(1); + expect(await response.json()).toEqual({test: "success"}) + expect(response.url).toEqual('https://resilient.is/test.json') + }); + + test("it should fetch content when integrity data not provided, by also fetching the integrity data URL", async () => { + require("../../plugins/signed-integrity.js"); + + const response = await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/test.json', {}); + + expect(resolvingFetch).toHaveBeenCalledTimes(2); + expect(resolvingFetch).toHaveBeenNthCalledWith(1, 'https://resilient.is/test.json.integrity') + expect(await response.json()).toEqual({test: "success"}) + expect(response.url).toEqual('https://resilient.is/test.json') + }); + + test("it should fetch content when integrity data not provided, and integrity data URL 404s", async () => { + require("../../plugins/signed-integrity.js"); + + const response = await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/fail.json', {}); + + expect(resolvingFetch).toHaveBeenCalledTimes(2); + expect(resolvingFetch).toHaveBeenNthCalledWith(1, 'https://resilient.is/fail.json.integrity') + expect(await response.json()).toEqual({test: "success"}) + expect(response.url).toEqual('https://resilient.is/fail.json') + }); + + test("it should refuse to fetch content when integrity data not provided and integrity data URL 404s, but requireIntegrity is set to true", async () => { + require("../../plugins/signed-integrity.js"); + + var newInit = init + newInit.requireIntegrity = true + + expect.assertions(4); + try { + const response = await LibResilientPluginConstructors.get('signed-integrity')(LR, newInit).fetch('https://resilient.is/fail.json', {}); + } catch (e) { + expect(resolvingFetch).toHaveBeenCalledTimes(1); + expect(resolvingFetch).toHaveBeenCalledWith('https://resilient.is/fail.json.integrity') + expect(e).toBeInstanceOf(Error) + expect(e.toString()).toMatch('No integrity data available, though required.') + } + }); + +}); diff --git a/plugins/signed-integrity.js b/plugins/signed-integrity.js new file mode 100644 index 0000000..5f86601 --- /dev/null +++ b/plugins/signed-integrity.js @@ -0,0 +1,100 @@ +/* ========================================================================= *\ +|* === Signed Integrity: content integrity using signed integrity data === *| +\* ========================================================================= */ + +// no polluting of the global namespace please +(function(LRPC){ + // this never changes + const pluginName = "signed-integrity" + LRPC.set(pluginName, (LR, init={})=>{ + + /* + * plugin config settings + */ + + // sane defaults + let defaultConfig = { + // public key used for signature verification on integrity files + pubkey: null, + // suffix of integrity data files + integrityFileSuffix: '.integrity', + // is integrity data required for any fetched content? + // + // NOTICE: this requires *any* integrity data to be available; if integrity data + // NOTICE: is already set in the original Request, that's considered enough + // + // TODO: do we need to have forceSignedIntegrity too, to *force* usage of signed integrity data? + requireIntegrity: false, + // plugin used for actually fetching the content + uses: [{ + // by default using standard fetch(), + // leaning on browser implementations of subresource integrity checks + // + // if using a different transport plugin, remember to make sure that it verifies + // subresource integrity when provided, or wrap it in an integrity-checking + // wrapper plugin (like integrity-check) to make sure integrity is in fact + // verified when present + name: "fetch" + }] + } + + // merge the defaults with settings from LibResilientConfig + let config = {...defaultConfig, ...init} + + // reality check: if no wrapped plugin configured, or more than one, complain + if (config.uses.length != 1) { + throw new Error(`Expected exactly one plugin to wrap, but ${config.uses.length} configured.`) + } + + /** + * getting content using the configured plugin, + * but also making sure integrity data file is fetched, signature checked, + * and integrity data set in the init for the wrapped plugin + */ + let fetchContent = async (url, init={}) => { + + // do we have integrity data in init? + if (!('integrity' in init)) { + // integrity data file URL + var integrityUrl = url + config.integrityFileSuffix + + // let's try to get integrity data + LR.log(pluginName, `fetching integrity file:\n- ${integrityUrl}\nusing plugin:\n- ${config.uses[0].name}`) + var integrityResponse = await config.uses[0].fetch(integrityUrl) + + // did we get anything sane? + if (integrityResponse.status == 200) { + LR.log(pluginName, `fetched integrity data file`) + // this is where magic happens + } else { + LR.log(pluginName, `fetching integrity data failed: ${integrityResponse.status} ${integrityResponse.statusText}`) + } + } + + // at this point we should have integrity in init one way or another + if (config.requireIntegrity && !('integrity' in init)) { + throw new Error(`No integrity data available, though required.`) + } + + LR.log(pluginName, `fetching content using: [${config.uses[0].name}]`) + // fetch using the configured wrapped plugin + // + // NOTICE: we have no way of knowing if the wrapped plugin performs any actual integrity check + // NOTICE: if the wrapped plugin doesn't actually check integrity, + // NOTICE: setting integrity here is not going to do anything + return config.uses[0].fetch(url, init) + } + + // and add ourselves to it + // with some additional metadata + return { + name: pluginName, + description: `Fetching signed integrity data, using: [${config.uses.map(p=>p.name).join(', ')}]`, + version: 'COMMIT_UNKNOWN', + fetch: fetchContent, + uses: config.uses + } + + }) +// done with not polluting the global namespace +})(LibResilientPluginConstructors)