const { subtle } = require('crypto').webcrypto; describe("plugin: signed-integrity", () => { var keypair = null async function generateECDSAKeypair() { if (keypair == null) { keypair = await subtle.generateKey({ name: "ECDSA", namedCurve: "P-384" }, true, ["sign", "verify"] ); } return keypair; } async function getArmouredKey(key) { return JSON.stringify(await subtle.exportKey('jwk', key)) } beforeEach(async () => { 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.subtle = subtle global.btoa = (bin) => { return Buffer.from(bin, 'binary').toString('base64') } global.atob = (ascii) => { return Buffer.from(ascii, 'base64').toString('binary') } global.LibResilientPluginConstructors = new Map() LR = { log: (component, ...items)=>{ console.debug(component + ' :: ', ...items) } } // debug console.log(await getArmouredKey((await generateECDSAKeypair()).publicKey)) // ES384: ECDSA using P-384 and SHA-384 var header = btoa('{"alg": "ES384"}').replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '') var payload = btoa('{"integrity": "sha256-eiMrFuthzteJuj8fPwUMyNQMb2SMW7VITmmt2oAxGj0="}').replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '') // get a signature var signature = await subtle.sign( { name: "ECDSA", hash: {name: "SHA-384"} }, (await generateECDSAKeypair()).privateKey, (header + '.' + payload) ) // prepare it for inclusion in the JWT signature = btoa(signature).replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '') // need to test with bad algo! var noneHeader = btoa('{"alg": "none"}').replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '') // get an invalid signature // an ECDSA signature for {alg: none} header makes zero sense var noneSignature = await subtle.sign( { name: "ECDSA", hash: {name: "SHA-384"} }, (await generateECDSAKeypair()).privateKey, (noneHeader + '.' + payload) ) // prepare it for inclusion in the JWT noneSignature = btoa(noneSignature).replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '') // prepare stuff for invalid JWT JSON test var invalidPayload = btoa('not a valid JSON string').replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '') // get an valid signature for invalid payload var invalidPayloadSignature = await subtle.sign( { name: "ECDSA", hash: {name: "SHA-384"} }, (await generateECDSAKeypair()).privateKey, (header + '.' + invalidPayload) ) // prepare it for inclusion in the JWT invalidPayloadSignature = btoa(invalidPayloadSignature).replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '') // prepare stuff for JWT payload without integrity test var noIntegrityPayload = btoa('{"no": "integrity"}').replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '') // get an valid signature for invalid payload var noIntegrityPayloadSignature = await subtle.sign( { name: "ECDSA", hash: {name: "SHA-384"} }, (await generateECDSAKeypair()).privateKey, (header + '.' + noIntegrityPayload) ) // prepare it for inclusion in the JWT noIntegrityPayloadSignature = btoa(noIntegrityPayloadSignature).replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '') 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 = header + '.' + payload + '.' + signature // testing 404 not found on the integrity URL } else if (url == 'https://resilient.is/not-found.json.integrity') { content = '{"test": "fail"}' status = 404 statusText = "Not Found" // testing invalid base64-encoded data } else if (url == 'https://resilient.is/invalid-base64.json.integrity') { // for this test to work correctly the length must be (n*4)+1 content = header + '.' + payload + '.' + 'badbase64' // testing "alg: none" on the integrity JWT } else if (url == 'https://resilient.is/alg-none.json.integrity') { content = noneHeader + '.' + payload + '.' // testing bad signature on the integrity JWT } else if (url == 'https://resilient.is/bad-signature.json.integrity') { content = header + '.' + payload + '.' + noneSignature // testing invalid payload } else if (url == 'https://resilient.is/invalid-payload.json.integrity') { content = header + '.' + invalidPayload + '.' + invalidPayloadSignature // testing payload without integrity data } else if (url == 'https://resilient.is/no-integrity.json.integrity') { content = header + '.' + noIntegrityPayload + '.' + noIntegrityPayloadSignature } 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, publicKey: await subtle.exportKey('jwk', (await generateECDSAKeypair()).publicKey) } 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 if the configured public key is impossible to load", async () => { require("../../plugins/signed-integrity.js"); init.publicKey = 'NOTAKEY' 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('Unable to load the public key') } }); 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/not-found.json', {}); expect(resolvingFetch).toHaveBeenCalledTimes(2); expect(resolvingFetch).toHaveBeenNthCalledWith(1, 'https://resilient.is/not-found.json.integrity') expect(await response.json()).toEqual({test: "success"}) expect(response.url).toEqual('https://resilient.is/not-found.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/not-found.json', {}); } catch (e) { expect(resolvingFetch).toHaveBeenCalledTimes(1); expect(resolvingFetch).toHaveBeenCalledWith('https://resilient.is/not-found.json.integrity') expect(e).toBeInstanceOf(Error) expect(e.toString()).toMatch('No integrity data available, though required.') } }); test("it should refuse to fetch content when integrity data not provided and integrity data URL is fetched, but JWT is invalid", async () => { require("../../plugins/signed-integrity.js"); expect.assertions(4); try { const response = await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/invalid-base64.json', {}); } catch (e) { expect(resolvingFetch).toHaveBeenCalledTimes(1); expect(resolvingFetch).toHaveBeenCalledWith('https://resilient.is/invalid-base64.json.integrity') expect(e).toBeInstanceOf(Error) expect(e.toString()).toMatch('Invalid base64-encoded string') } }); test("it should refuse to fetch content when integrity data not provided and integrity data URL is fetched, but JWT uses alg: none", async () => { require("../../plugins/signed-integrity.js"); expect.assertions(4); try { const response = await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/alg-none.json', {}); } catch (e) { expect(resolvingFetch).toHaveBeenCalledTimes(1); expect(resolvingFetch).toHaveBeenCalledWith('https://resilient.is/alg-none.json.integrity') expect(e).toBeInstanceOf(Error) expect(e.toString()).toMatch('JWT seems invalid (one or more sections are empty)') } }); test("it should refuse to fetch content when integrity data not provided and integrity data URL is fetched, but JWT signature check fails", async () => { require("../../plugins/signed-integrity.js"); expect.assertions(4); try { const response = await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/bad-signature.json', {}); } catch (e) { expect(resolvingFetch).toHaveBeenCalledTimes(1); expect(resolvingFetch).toHaveBeenCalledWith('https://resilient.is/bad-signature.json.integrity') expect(e).toBeInstanceOf(Error) expect(e.toString()).toMatch('JWT signature validation failed') } }); test("it should refuse to fetch content when integrity data not provided and integrity data URL is fetched, but JWT payload is unparseable", async () => { require("../../plugins/signed-integrity.js"); expect.assertions(4); try { const response = await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/invalid-payload.json', {}); } catch (e) { expect(resolvingFetch).toHaveBeenCalledTimes(1); expect(resolvingFetch).toHaveBeenCalledWith('https://resilient.is/invalid-payload.json.integrity') expect(e).toBeInstanceOf(Error) expect(e.toString()).toMatch('JWT payload parsing failed') } }); test("it should refuse to fetch content when integrity data not provided and integrity data URL is fetched, but JWT payload does not contain integrity data", async () => { require("../../plugins/signed-integrity.js"); expect.assertions(4); try { const response = await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/no-integrity.json', {}); } catch (e) { expect(resolvingFetch).toHaveBeenCalledTimes(1); expect(resolvingFetch).toHaveBeenCalledWith('https://resilient.is/no-integrity.json.integrity') expect(e).toBeInstanceOf(Error) expect(e.toString()).toMatch('JWT payload did not contain integrity data') } }); test("it should fetch and verify content, when integrity data not provided, by fetching the integrity data URL and using integrity data from it", 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(resolvingFetch).toHaveBeenNthCalledWith(2, 'https://resilient.is/test.json', {integrity: "sha256-eiMrFuthzteJuj8fPwUMyNQMb2SMW7VITmmt2oAxGj0="}) expect(await response.json()).toEqual({test: "success"}) expect(response.url).toEqual('https://resilient.is/test.json') }); });