kopia lustrzana https://gitlab.com/rysiekpl/libresilient
Merge branch 'wip-cli' into 'master'
Command Line Interface improvements See merge request rysiekpl/libresilient!18merge-requests/23/head
commit
bb941b7efa
|
@ -27,6 +27,15 @@ libresilient-test:
|
|||
tags:
|
||||
- docker
|
||||
- linux
|
||||
|
||||
lrcli-test:
|
||||
image: denoland/deno:1.28.3
|
||||
stage: test
|
||||
script:
|
||||
- deno test --quiet --allow-read --import-map=__denotests__/importmap.json __denotests__/
|
||||
tags:
|
||||
- docker
|
||||
- linux
|
||||
|
||||
stages:
|
||||
- test
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
import { assertEquals } from "https://deno.land/std@0.167.0/testing/asserts.ts";
|
||||
import { main } from '../../cli/lrcli.js';
|
||||
|
||||
Deno.test("basic usage info", async () => {
|
||||
// init
|
||||
let r
|
||||
// no args
|
||||
r = await main([])
|
||||
assertEquals(r, 1)
|
||||
// -h/--help
|
||||
r = await main(['-h'])
|
||||
assertEquals(r, 0)
|
||||
r = await main(['--help'])
|
||||
assertEquals(r, 0)
|
||||
});
|
||||
|
||||
Deno.test("non-existent plugin handling", async () => {
|
||||
// init
|
||||
let r
|
||||
// just the plugin name
|
||||
r = await main(['no-such-plugin'])
|
||||
assertEquals(r, 2)
|
||||
// plugin name with different combinations of help flag
|
||||
r = await main(['no-such-plugin', '--help'])
|
||||
assertEquals(r, 2)
|
||||
r = await main(['no-such-plugin', '-h'])
|
||||
assertEquals(r, 2)
|
||||
r = await main(['--help', 'no-such-plugin'])
|
||||
assertEquals(r, 2)
|
||||
r = await main(['-h', 'no-such-plugin'])
|
||||
assertEquals(r, 2)
|
||||
});
|
||||
|
||||
//
|
||||
// tests below need an imports map
|
||||
//
|
||||
|
||||
Deno.test("plugin loading", async () => {
|
||||
const r = await main(['simple-plugin', '--help'])
|
||||
assertEquals(r, 0)
|
||||
});
|
||||
|
||||
Deno.test("plugin action processing", async () => {
|
||||
// init
|
||||
let r
|
||||
// non-existent action
|
||||
r = await main(['simple-plugin', 'non-such-action'])
|
||||
assertEquals(r, 4)
|
||||
r = await main(['simple-plugin', '-h', 'non-such-action'])
|
||||
assertEquals(r, 0)
|
||||
r = await main(['simple-plugin', '--help', 'non-such-action'])
|
||||
assertEquals(r, 0)
|
||||
r = await main(['simple-plugin', 'non-such-action', '-h'])
|
||||
assertEquals(r, 4)
|
||||
r = await main(['simple-plugin', 'non-such-action', '--help'])
|
||||
assertEquals(r, 4)
|
||||
// action that exists
|
||||
r = await main(['simple-plugin', 'test-action'])
|
||||
assertEquals(r, 0)
|
||||
r = await main(['simple-plugin', 'test-action', '-h'])
|
||||
assertEquals(r, 0)
|
||||
r = await main(['simple-plugin', 'test-action', '--help'])
|
||||
assertEquals(r, 0)
|
||||
});
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"imports": {
|
||||
"../plugins/simple-plugin/cli.js": "./mocks/simple-plugin.js"
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
hello world
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"publicKey": {
|
||||
"kty": "EC",
|
||||
"crv": "P-384",
|
||||
"alg": "ES384",
|
||||
"x": "rrFawYTuFo8ZjoDxaztUU-c_RAwjw1Y9Tp3j4nH4WsY2Zlizf40Mvz_0BUkVVZCw",
|
||||
"y": "HaFct6PVK2CQ7ZT2SHClnN-knmGfjY_DFwc6qrAu1s0DFZ8fEUuNdmkTlj9T4NQw",
|
||||
"key_ops": [
|
||||
"verify"
|
||||
],
|
||||
"ext": true
|
||||
},
|
||||
"privateKey": {
|
||||
"kty": "EC",
|
||||
"crv": "P-384",
|
||||
"alg": "ES384",
|
||||
"x": "rrFawYTuFo8ZjoDxaztUU-c_RAwjw1Y9Tp3j4nH4WsY2Zlizf40Mvz_0BUkVVZCw",
|
||||
"y": "HaFct6PVK2CQ7ZT2SHClnN-knmGfjY_DFwc6qrAu1s0DFZ8fEUuNdmkTlj9T4NQw",
|
||||
"d": "zNpKLEqrWozLwaxlxnEa8njDLVQbwfHxX0OBNqB0jDl5qLLDcYF9NtHtXGzUWF5r",
|
||||
"key_ops": [
|
||||
"sign"
|
||||
],
|
||||
"ext": true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
let testAction = () => {
|
||||
return "testAction run!"
|
||||
}
|
||||
|
||||
const pluginName = "simple-plugin"
|
||||
const pluginDescription = "A simple plugin for testing"
|
||||
const pluginVersion = "0.0.1"
|
||||
const pluginActions = {
|
||||
'test-action': {
|
||||
run: testAction,
|
||||
description: "Test action of a test plugin"
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
pluginName as name,
|
||||
pluginDescription as description,
|
||||
pluginVersion as version,
|
||||
pluginActions as actions
|
||||
}
|
|
@ -0,0 +1,93 @@
|
|||
import {
|
||||
assert,
|
||||
assertThrows,
|
||||
assertRejects,
|
||||
assertEquals
|
||||
} from "https://deno.land/std@0.167.0/testing/asserts.ts";
|
||||
|
||||
Deno.test("plugin loads", async () => {
|
||||
const bi = await import('../../plugins/basic-integrity/cli.js')
|
||||
assert("name" in bi)
|
||||
assert(bi.name == "basic-integrity")
|
||||
assert("description" in bi)
|
||||
assert("actions" in bi)
|
||||
});
|
||||
|
||||
Deno.test("get-integrity action defined", async () => {
|
||||
const bi = await import('../../plugins/basic-integrity/cli.js')
|
||||
assert("get-integrity" in bi.actions)
|
||||
const gi = bi.actions["get-integrity"]
|
||||
assert("run" in gi)
|
||||
assert("description" in gi)
|
||||
assert("arguments" in gi)
|
||||
const gia = gi.arguments
|
||||
assert("_" in gia)
|
||||
assert("algorithm" in gia)
|
||||
assert("output" in gia)
|
||||
assert("name" in gia._)
|
||||
assert("description" in gia._)
|
||||
assert("description" in gia.algorithm)
|
||||
assert("collect" in gia.algorithm)
|
||||
assert(gia.algorithm.collect)
|
||||
assert("string" in gia.algorithm)
|
||||
assert(gia.algorithm.string)
|
||||
assert("description" in gia.output)
|
||||
assert("collect" in gia.output)
|
||||
assert(!gia.output.collect)
|
||||
assert("string" in gia.output)
|
||||
assert(gia.output.string)
|
||||
});
|
||||
|
||||
// this is a separate test in order to catch any changing defaults
|
||||
Deno.test("get-integrity action defaults", async () => {
|
||||
const bi = await import('../../plugins/basic-integrity/cli.js')
|
||||
const gia = bi.actions["get-integrity"].arguments
|
||||
assert("default" in gia.algorithm)
|
||||
assert(gia.algorithm.default == "SHA-256")
|
||||
assert("default" in gia.output)
|
||||
assert(gia.output.default == "json")
|
||||
});
|
||||
|
||||
Deno.test("get-integrity verifies arguments are sane", async () => {
|
||||
const bi = await import('../../plugins/basic-integrity/cli.js')
|
||||
const gi = bi.actions["get-integrity"]
|
||||
assertRejects(gi.run, Error, "Expected non-empty list of files to generate digests of.")
|
||||
assertRejects(async ()=>{
|
||||
await gi.run(['no-such-file'])
|
||||
}, Error, "No such file or directory")
|
||||
assertRejects(async ()=>{
|
||||
await gi.run(['irrelevant'], [])
|
||||
}, Error, "Expected non-empty list of algorithms to use.")
|
||||
assertRejects(async ()=>{
|
||||
await gi.run(['irrelevant'], ['SHA-384'], false)
|
||||
}, Error, "Expected either 'json' or 'text' as output type to generate.")
|
||||
});
|
||||
|
||||
Deno.test("get-integrity handles paths in a sane way", async () => {
|
||||
const bi = await import('../../plugins/basic-integrity/cli.js')
|
||||
const gi = bi.actions["get-integrity"]
|
||||
assertEquals(await gi.run(['./']), '{}')
|
||||
assertEquals(await gi.run(['./__denotests__/mocks/hello.txt']), '{"./__denotests__/mocks/hello.txt":["sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="]}')
|
||||
assertEquals(await gi.run(['./', './__denotests__/mocks/hello.txt']), '{"./__denotests__/mocks/hello.txt":["sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="]}')
|
||||
});
|
||||
|
||||
Deno.test("get-integrity handles algos argument in a sane way", async () => {
|
||||
const bi = await import('../../plugins/basic-integrity/cli.js')
|
||||
const gi = bi.actions["get-integrity"]
|
||||
assertRejects(async ()=>{
|
||||
await gi.run(['./__denotests__/mocks/hello.txt'], ['BAD-ALG'])
|
||||
}, Error, 'Unrecognized algorithm name')
|
||||
assertEquals(await gi.run(['./__denotests__/mocks/hello.txt'], ['SHA-256']), '{"./__denotests__/mocks/hello.txt":["sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="]}')
|
||||
assertEquals(await gi.run(['./__denotests__/mocks/hello.txt'], ['SHA-384']), '{"./__denotests__/mocks/hello.txt":["sha384-/b2OdaZ/KfcBpOBAOF4uI5hjA+oQI5IRr5B/y7g1eLPkF8txzmRu/QgZ3YwIjeG9"]}')
|
||||
assertEquals(await gi.run(['./__denotests__/mocks/hello.txt'], ['SHA-512']), '{"./__denotests__/mocks/hello.txt":["sha512-MJ7MSJwS1utMxA9QyQLytNDtd+5RGnx6m808qG1M2G+YndNbxf9JlnDaNCVbRbDP2DDoH2Bdz33FVC6TrpzXbw=="]}')
|
||||
assertEquals(await gi.run(['./__denotests__/mocks/hello.txt'], ['SHA-256', 'SHA-384', 'SHA-512']), '{"./__denotests__/mocks/hello.txt":["sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek=","sha384-/b2OdaZ/KfcBpOBAOF4uI5hjA+oQI5IRr5B/y7g1eLPkF8txzmRu/QgZ3YwIjeG9","sha512-MJ7MSJwS1utMxA9QyQLytNDtd+5RGnx6m808qG1M2G+YndNbxf9JlnDaNCVbRbDP2DDoH2Bdz33FVC6TrpzXbw=="]}')
|
||||
});
|
||||
|
||||
Deno.test("get-integrity handles output argument in a sane way", async () => {
|
||||
const bi = await import('../../plugins/basic-integrity/cli.js')
|
||||
const gi = bi.actions["get-integrity"]
|
||||
assertEquals(await gi.run(['./__denotests__/mocks/hello.txt'], ['SHA-256'], 'text'), './__denotests__/mocks/hello.txt: sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek=\n')
|
||||
assertEquals(await gi.run(['./__denotests__/mocks/hello.txt'], ['SHA-384'], 'text'), './__denotests__/mocks/hello.txt: sha384-/b2OdaZ/KfcBpOBAOF4uI5hjA+oQI5IRr5B/y7g1eLPkF8txzmRu/QgZ3YwIjeG9\n')
|
||||
assertEquals(await gi.run(['./__denotests__/mocks/hello.txt'], ['SHA-512'], 'text'), './__denotests__/mocks/hello.txt: sha512-MJ7MSJwS1utMxA9QyQLytNDtd+5RGnx6m808qG1M2G+YndNbxf9JlnDaNCVbRbDP2DDoH2Bdz33FVC6TrpzXbw==\n')
|
||||
assertEquals(await gi.run(['./__denotests__/mocks/hello.txt'], ['SHA-256', 'SHA-384', 'SHA-512'], 'text'), './__denotests__/mocks/hello.txt: sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek= sha384-/b2OdaZ/KfcBpOBAOF4uI5hjA+oQI5IRr5B/y7g1eLPkF8txzmRu/QgZ3YwIjeG9 sha512-MJ7MSJwS1utMxA9QyQLytNDtd+5RGnx6m808qG1M2G+YndNbxf9JlnDaNCVbRbDP2DDoH2Bdz33FVC6TrpzXbw==\n')
|
||||
});
|
|
@ -0,0 +1,334 @@
|
|||
import {
|
||||
assert,
|
||||
assertThrows,
|
||||
assertRejects,
|
||||
assertEquals,
|
||||
assertStringIncludes,
|
||||
assertObjectMatch
|
||||
} from "https://deno.land/std@0.167.0/testing/asserts.ts";
|
||||
|
||||
// this needs to be the same as the pubkey in:
|
||||
// ./__denotests__/mocks/keyfile.json
|
||||
var pubkey = {
|
||||
"kty": "EC",
|
||||
"crv": "P-384",
|
||||
"alg": "ES384",
|
||||
"x": "rrFawYTuFo8ZjoDxaztUU-c_RAwjw1Y9Tp3j4nH4WsY2Zlizf40Mvz_0BUkVVZCw",
|
||||
"y": "HaFct6PVK2CQ7ZT2SHClnN-knmGfjY_DFwc6qrAu1s0DFZ8fEUuNdmkTlj9T4NQw",
|
||||
"key_ops": [
|
||||
"verify"
|
||||
],
|
||||
"ext": true
|
||||
}
|
||||
|
||||
/**
|
||||
* helper function — decode a b64url-encoded string
|
||||
*/
|
||||
let b64urlDecode = (data) => {
|
||||
data = data.replace(/_/g, '/').replace(/-/g, '+')
|
||||
data += '='.repeat((4 - (data.length % 4)) % 4)
|
||||
return atob(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* helper function — verify a signed JWT using a provided key
|
||||
*/
|
||||
let verifySignedJWT = async (jwt, key) => {
|
||||
|
||||
// working in sections
|
||||
jwt = jwt.split('.')
|
||||
|
||||
// sections cannot be empty
|
||||
if ( (jwt[0].length == 0) || (jwt[1].length == 0) || (jwt[2].length == 0) ) {
|
||||
throw new Error('JWT is invalid, one or more sections are empty.')
|
||||
}
|
||||
|
||||
// load the key
|
||||
key = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
key,
|
||||
{
|
||||
name: 'ECDSA',
|
||||
namedCurve: 'P-384'
|
||||
},
|
||||
true,
|
||||
['verify']
|
||||
)
|
||||
|
||||
// checking the header
|
||||
var header = JSON.parse(b64urlDecode(jwt[0]))
|
||||
if (!("alg" in header) || (header.alg != "ES384")) {
|
||||
throw new Error('Expected header to contain alg field set to "ES384"')
|
||||
}
|
||||
|
||||
// get the signature in a usable format
|
||||
var signature = Uint8Array
|
||||
.from(
|
||||
Array
|
||||
.from(b64urlDecode(jwt[2]))
|
||||
.map(e=>e.charCodeAt(0))
|
||||
).buffer
|
||||
|
||||
// return the result of signature verification
|
||||
return await crypto.subtle.verify(
|
||||
{
|
||||
name: "ECDSA",
|
||||
hash: {name: "SHA-384"}
|
||||
},
|
||||
key,
|
||||
signature,
|
||||
new TextEncoder("utf-8").encode(jwt[0] + '.' + jwt[1])
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Deno.test("plugin loads", async () => {
|
||||
const bi = await import('../../plugins/signed-integrity/cli.js')
|
||||
assert("name" in bi)
|
||||
assert(bi.name == "signed-integrity")
|
||||
assert("description" in bi)
|
||||
assert("actions" in bi)
|
||||
});
|
||||
|
||||
Deno.test("gen-integrity action defined", async () => {
|
||||
const bi = await import('../../plugins/signed-integrity/cli.js')
|
||||
assert("gen-integrity" in bi.actions)
|
||||
|
||||
const gi = bi.actions["gen-integrity"]
|
||||
assert("run" in gi)
|
||||
assert("description" in gi)
|
||||
assert("arguments" in gi)
|
||||
|
||||
const gia = gi.arguments
|
||||
assert("_" in gia)
|
||||
assert("keyfile" in gia)
|
||||
assert("algorithm" in gia)
|
||||
assert("output" in gia)
|
||||
assert("extension" in gia)
|
||||
|
||||
assert("name" in gia._)
|
||||
assert("description" in gia._)
|
||||
|
||||
assert("description" in gia.keyfile)
|
||||
assert("string" in gia.keyfile)
|
||||
assert(!("collect" in gia.keyfile))
|
||||
assert(gia.keyfile.string)
|
||||
|
||||
assert("description" in gia.algorithm)
|
||||
assert("collect" in gia.algorithm)
|
||||
assert(gia.algorithm.collect)
|
||||
assert("string" in gia.algorithm)
|
||||
assert(gia.algorithm.string)
|
||||
|
||||
assert("description" in gia.output)
|
||||
assert(!("collect" in gia.output))
|
||||
assert("string" in gia.output)
|
||||
assert(gia.output.string)
|
||||
|
||||
assert("description" in gia.extension)
|
||||
assert(!("collect" in gia.extension))
|
||||
assert("string" in gia.extension)
|
||||
assert(gia.extension.string)
|
||||
});
|
||||
|
||||
// this is a separate test in order to catch any changing defaults
|
||||
Deno.test("gen-integrity action defaults", async () => {
|
||||
const bi = await import('../../plugins/signed-integrity/cli.js')
|
||||
const gia = bi.actions["gen-integrity"].arguments
|
||||
assert("default" in gia.algorithm)
|
||||
assert(gia.algorithm.default == "SHA-256")
|
||||
assert("default" in gia.output)
|
||||
assert(gia.output.default == "json")
|
||||
assert("default" in gia.extension)
|
||||
assert(gia.extension.default == ".integrity")
|
||||
});
|
||||
|
||||
Deno.test("gen-integrity verifies arguments are sane", async () => {
|
||||
const bi = await import('../../plugins/signed-integrity/cli.js')
|
||||
const gi = bi.actions["gen-integrity"]
|
||||
assertRejects(gi.run, Error, "Expected non-empty list of paths to process.")
|
||||
assertRejects(async ()=>{
|
||||
await gi.run(['no-such-file'])
|
||||
}, Error, "No keyfile provided.")
|
||||
assertRejects(async ()=>{
|
||||
await gi.run(['no-such-file'], 'irrelevant')
|
||||
}, Error, "No such file or directory")
|
||||
assertRejects(async ()=>{
|
||||
await gi.run(['irrelevant'], 'irrelevant', [])
|
||||
}, Error, "Expected non-empty list of algorithms to use.")
|
||||
assertRejects(async ()=>{
|
||||
await gi.run(['irrelevant'], 'irrelevant', ['SHA-384'], false)
|
||||
}, Error, "Expected 'json', 'text', or 'files' as output type.")
|
||||
assertRejects(async ()=>{
|
||||
await gi.run(['irrelevant'], 'irrelevant', ['SHA-384'], 'files', false)
|
||||
}, Error, "No extension provided.")
|
||||
// extension is ignored even if incorrect when output is not 'files'
|
||||
assertRejects(async ()=>{
|
||||
await gi.run(['irrelevant'], 'irrelevant', ['SHA-384'], 'text', false)
|
||||
}, Error, "Failed to load private key from 'irrelevant': No such file or directory")
|
||||
assertRejects(async ()=>{
|
||||
await gi.run(['irrelevant'], 'irrelevant', ['SHA-384'], 'json', false)
|
||||
}, Error, "Failed to load private key from 'irrelevant': No such file or directory")
|
||||
});
|
||||
|
||||
Deno.test("gen-integrity handles paths correctly", async () => {
|
||||
const bi = await import('../../plugins/signed-integrity/cli.js')
|
||||
const gi = bi.actions["gen-integrity"]
|
||||
assertRejects(async ()=>{
|
||||
await gi.run(['./'], 'non-existent')
|
||||
}, Error, "Failed to load private key from 'non-existent'")
|
||||
assertRejects(async ()=>{
|
||||
await gi.run(['./'], './')
|
||||
}, Error, "ailed to load private key from './': Is a directory")
|
||||
assertEquals(
|
||||
await gi.run(['./'], './__denotests__/mocks/keyfile.json'),
|
||||
'{}'
|
||||
)
|
||||
assertStringIncludes(
|
||||
await gi.run(['./__denotests__/mocks/hello.txt'], './__denotests__/mocks/keyfile.json'),
|
||||
'"./__denotests__/mocks/hello.txt":"eyJhbGciOiAiRVMzODQifQ.eyJpbnRlZ3JpdHkiOiAic2hhMjU2LXVVMG51Wk5OUGdpbExsTFgybjJyK3NTRTcrTjZVNER1a0lqM3JPTHZ6ZWs9In0.'
|
||||
)
|
||||
});
|
||||
|
||||
Deno.test("gen-integrity handles algos argument correctly", async () => {
|
||||
const bi = await import('../../plugins/signed-integrity/cli.js')
|
||||
const gi = bi.actions["gen-integrity"]
|
||||
assertRejects(async ()=>{
|
||||
await gi.run(['./__denotests__/mocks/hello.txt'], './__denotests__/mocks/keyfile.json', ['BAD-ALG'])
|
||||
}, Error, 'Unrecognized algorithm name')
|
||||
|
||||
// helper function
|
||||
let getGeneratedTestIntegrity = async (algos) => {
|
||||
let integrity = JSON.parse(await gi.run(
|
||||
['./__denotests__/mocks/hello.txt'],
|
||||
'./__denotests__/mocks/keyfile.json',
|
||||
algos)
|
||||
)
|
||||
integrity = b64urlDecode(integrity["./__denotests__/mocks/hello.txt"].split('.')[1])
|
||||
return integrity
|
||||
}
|
||||
|
||||
assertEquals(await getGeneratedTestIntegrity(['SHA-256']), '{"integrity": "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="}')
|
||||
assertEquals(await getGeneratedTestIntegrity(['SHA-384']), '{"integrity": "sha384-/b2OdaZ/KfcBpOBAOF4uI5hjA+oQI5IRr5B/y7g1eLPkF8txzmRu/QgZ3YwIjeG9"}')
|
||||
assertEquals(await getGeneratedTestIntegrity(['SHA-512']), '{"integrity": "sha512-MJ7MSJwS1utMxA9QyQLytNDtd+5RGnx6m808qG1M2G+YndNbxf9JlnDaNCVbRbDP2DDoH2Bdz33FVC6TrpzXbw=="}')
|
||||
assertEquals(await getGeneratedTestIntegrity(['SHA-256', 'SHA-384', 'SHA-512']), '{"integrity": "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek= sha384-/b2OdaZ/KfcBpOBAOF4uI5hjA+oQI5IRr5B/y7g1eLPkF8txzmRu/QgZ3YwIjeG9 sha512-MJ7MSJwS1utMxA9QyQLytNDtd+5RGnx6m808qG1M2G+YndNbxf9JlnDaNCVbRbDP2DDoH2Bdz33FVC6TrpzXbw=="}')
|
||||
});
|
||||
|
||||
Deno.test("gen-integrity text output is correct", async () => {
|
||||
const bi = await import('../../plugins/signed-integrity/cli.js')
|
||||
const gi = bi.actions["gen-integrity"]
|
||||
|
||||
let getGeneratedTestIntegrity = async (algos) => {
|
||||
let result = await gi.run(
|
||||
['./__denotests__/mocks/hello.txt'],
|
||||
'./__denotests__/mocks/keyfile.json',
|
||||
algos,
|
||||
'text'
|
||||
)
|
||||
result = result.split(' ')
|
||||
return [result[0], b64urlDecode(result[1].split('.')[1])]
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
await getGeneratedTestIntegrity(['SHA-256']),
|
||||
[
|
||||
"./__denotests__/mocks/hello.txt:",
|
||||
'{"integrity": "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="}'
|
||||
]
|
||||
)
|
||||
assertEquals(
|
||||
await getGeneratedTestIntegrity(['SHA-384']),
|
||||
[
|
||||
"./__denotests__/mocks/hello.txt:",
|
||||
'{"integrity": "sha384-/b2OdaZ/KfcBpOBAOF4uI5hjA+oQI5IRr5B/y7g1eLPkF8txzmRu/QgZ3YwIjeG9"}'
|
||||
]
|
||||
)
|
||||
assertEquals(
|
||||
await getGeneratedTestIntegrity(['SHA-512']),
|
||||
[
|
||||
"./__denotests__/mocks/hello.txt:",
|
||||
'{"integrity": "sha512-MJ7MSJwS1utMxA9QyQLytNDtd+5RGnx6m808qG1M2G+YndNbxf9JlnDaNCVbRbDP2DDoH2Bdz33FVC6TrpzXbw=="}'
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
assertEquals(
|
||||
await getGeneratedTestIntegrity(['SHA-256', 'SHA-384', 'SHA-512']),
|
||||
[
|
||||
"./__denotests__/mocks/hello.txt:",
|
||||
'{"integrity": "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek= sha384-/b2OdaZ/KfcBpOBAOF4uI5hjA+oQI5IRr5B/y7g1eLPkF8txzmRu/QgZ3YwIjeG9 sha512-MJ7MSJwS1utMxA9QyQLytNDtd+5RGnx6m808qG1M2G+YndNbxf9JlnDaNCVbRbDP2DDoH2Bdz33FVC6TrpzXbw=="}'
|
||||
]
|
||||
)
|
||||
});
|
||||
|
||||
// TODO: "files" output mode, which will require mocking file writing routines
|
||||
|
||||
Deno.test("gen-integrity signs the data correctly", async () => {
|
||||
const bi = await import('../../plugins/signed-integrity/cli.js')
|
||||
const gi = bi.actions["gen-integrity"]
|
||||
let jwt = JSON.parse(await gi.run(['./__denotests__/mocks/hello.txt'], './__denotests__/mocks/keyfile.json'))
|
||||
assert(
|
||||
await verifySignedJWT(
|
||||
jwt['./__denotests__/mocks/hello.txt'],
|
||||
pubkey))
|
||||
})
|
||||
|
||||
Deno.test("get-pubkey works correctly", async () => {
|
||||
const bi = await import('../../plugins/signed-integrity/cli.js')
|
||||
const gp = bi.actions["get-pubkey"]
|
||||
assertRejects(gp.run, Error, "No keyfile provided.")
|
||||
assertRejects(async ()=>{
|
||||
await gp.run('no-such-file')
|
||||
}, Error, "No such file or directory")
|
||||
assertRejects(async ()=>{
|
||||
await gp.run(['no-such-file'])
|
||||
}, Error, "No such file or directory")
|
||||
assertEquals(
|
||||
await gp.run('./__denotests__/mocks/keyfile.json'),
|
||||
'{"kty":"EC","crv":"P-384","alg":"ES384","x":"rrFawYTuFo8ZjoDxaztUU-c_RAwjw1Y9Tp3j4nH4WsY2Zlizf40Mvz_0BUkVVZCw","y":"HaFct6PVK2CQ7ZT2SHClnN-knmGfjY_DFwc6qrAu1s0DFZ8fEUuNdmkTlj9T4NQw","key_ops":["verify"],"ext":true}'
|
||||
)
|
||||
assertEquals(
|
||||
await gp.run(['./__denotests__/mocks/keyfile.json', 'irrelevant']),
|
||||
'{"kty":"EC","crv":"P-384","alg":"ES384","x":"rrFawYTuFo8ZjoDxaztUU-c_RAwjw1Y9Tp3j4nH4WsY2Zlizf40Mvz_0BUkVVZCw","y":"HaFct6PVK2CQ7ZT2SHClnN-knmGfjY_DFwc6qrAu1s0DFZ8fEUuNdmkTlj9T4NQw","key_ops":["verify"],"ext":true}'
|
||||
)
|
||||
});
|
||||
|
||||
Deno.test("gen-keypair works correctly", async () => {
|
||||
const bi = await import('../../plugins/signed-integrity/cli.js')
|
||||
const gk = bi.actions["gen-keypair"]
|
||||
const keypair = JSON.parse(await gk.run())
|
||||
assert('privateKey' in keypair)
|
||||
assert('x' in keypair.privateKey)
|
||||
assert('y' in keypair.privateKey)
|
||||
assert('d' in keypair.privateKey)
|
||||
assertObjectMatch(
|
||||
keypair.privateKey,
|
||||
{
|
||||
kty: "EC",
|
||||
crv: "P-384",
|
||||
alg: "ES384",
|
||||
key_ops: [
|
||||
"sign"
|
||||
],
|
||||
ext: true
|
||||
}
|
||||
)
|
||||
assert('publicKey' in keypair)
|
||||
assert('x' in keypair.publicKey)
|
||||
assert('y' in keypair.publicKey)
|
||||
assert(!('d' in keypair.publicKey))
|
||||
assertObjectMatch(
|
||||
keypair.publicKey,
|
||||
{
|
||||
kty: "EC",
|
||||
crv: "P-384",
|
||||
alg: "ES384",
|
||||
key_ops: [
|
||||
"verify"
|
||||
],
|
||||
ext: true
|
||||
}
|
||||
)
|
||||
assert((keypair.privateKey.x == keypair.publicKey.x))
|
||||
assert((keypair.privateKey.y == keypair.publicKey.y))
|
||||
});
|
|
@ -0,0 +1,6 @@
|
|||
# LibResilient CLI
|
||||
|
||||
This is the command-line interface for LibResilient. It is supposed to understand the `config.json` configuration file, and simplify deployment of content so as to ensure that all configured transport plugins work as expected. It should also *test* if those channels work as expected.
|
||||
|
||||
This is a work in progress and not production-ready yet.
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
#!/usr/bin/env -S deno run --allow-read --allow-write
|
||||
|
||||
// TODO: handle the permissions better... somehow?
|
||||
|
||||
import { parse } from "https://deno.land/std/flags/mod.ts";
|
||||
|
||||
let getUsage = () => {
|
||||
let usage = `
|
||||
Command-line interface for LibResilient.
|
||||
|
||||
This script creates a common interface to CLI actions implemented by LibResilient plugins.
|
||||
|
||||
Usage:
|
||||
${new URL('', import.meta.url).toString().split('/').at(-1)} [options] [plugin-name [plugin-options]]
|
||||
|
||||
Options:
|
||||
|
||||
-h, --help [plugin-name]
|
||||
Print this message, if no plugin-name is given.
|
||||
If plugin-name is provided, print usage information of that plugin.
|
||||
|
||||
`
|
||||
|
||||
return usage
|
||||
}
|
||||
|
||||
|
||||
let getPluginActionUsage = (action, action_name) => {
|
||||
|
||||
// initialize
|
||||
let options = ""
|
||||
let positional = ""
|
||||
let positional_desc = ""
|
||||
|
||||
if ("arguments" in action) {
|
||||
for (const opt in action.arguments) {
|
||||
if (opt == '_') {
|
||||
continue
|
||||
}
|
||||
options += `\n --${opt}`
|
||||
if ("default" in action.arguments[opt]) {
|
||||
options += ` (default: ${action.arguments[opt].default})`
|
||||
}
|
||||
options += `\n ${action.arguments[opt].description}`
|
||||
options += `\n`
|
||||
}
|
||||
|
||||
if ('_' in action.arguments) {
|
||||
if ('name' in action.arguments._) {
|
||||
positional = ` <${action.arguments._.name}...>`
|
||||
} else {
|
||||
positional = ' <item...>'
|
||||
}
|
||||
positional_desc = `
|
||||
|
||||
${positional}
|
||||
${action.arguments._.description}`
|
||||
}
|
||||
}
|
||||
|
||||
let usage = ` ${action_name}${ (options != "") ? " [options...]" : "" }${positional}
|
||||
${action.description}${positional_desc}
|
||||
${options}`
|
||||
|
||||
return usage
|
||||
}
|
||||
|
||||
|
||||
let getPluginUsage = (plugin) => {
|
||||
let usage = `
|
||||
CLI plugin:
|
||||
${plugin.name}
|
||||
|
||||
Plugin Description:
|
||||
${plugin.description.replace('\n', '\n ')}
|
||||
|
||||
Usage:
|
||||
${new URL('', import.meta.url).toString().split('/').at(-1)} [general-options] ${plugin.name} [plugin-action [action-options]]
|
||||
|
||||
General Options:
|
||||
|
||||
-h, --help [plugin-name]
|
||||
Print this message, if no plugin-name is given.
|
||||
If plugin-name is provided, print usage information of that plugin.
|
||||
|
||||
Actions and Action Options:
|
||||
|
||||
`
|
||||
for (const action in plugin.actions) {
|
||||
usage += getPluginActionUsage(plugin.actions[action], action) + '\n'
|
||||
}
|
||||
|
||||
return usage
|
||||
}
|
||||
|
||||
let parsePluginActionArgs = (args, argdef) => {
|
||||
|
||||
var plugin_args_config = {
|
||||
boolean: [],
|
||||
string: [],
|
||||
alias: {},
|
||||
collect: [],
|
||||
negatable: [],
|
||||
unknown: null,
|
||||
default: {
|
||||
}
|
||||
}
|
||||
|
||||
for (const [argname, argconfig] of Object.entries(argdef)) {
|
||||
if (argname == '_') {
|
||||
continue;
|
||||
}
|
||||
if ( ("collect" in argconfig) && (argconfig.collect === true) ) {
|
||||
plugin_args_config.collect.push(argname)
|
||||
}
|
||||
if ( ("string" in argconfig) && (argconfig.string === true) ) {
|
||||
plugin_args_config.string.push(argname)
|
||||
}
|
||||
if ( ("boolean" in argconfig) && (argconfig.boolean === true) ) {
|
||||
plugin_args_config.boolean.push(argname)
|
||||
}
|
||||
if ( ("negatable" in argconfig) && (argconfig.negatable === true) ) {
|
||||
plugin_args_config.negatable.push(argname)
|
||||
}
|
||||
if ("default" in argconfig) {
|
||||
plugin_args_config.default[argname] = argconfig.default
|
||||
}
|
||||
}
|
||||
|
||||
var parsed = parse(args, plugin_args_config)
|
||||
|
||||
var result = []
|
||||
|
||||
// we want to keep the order of arguments
|
||||
// as defined in the plugin cli code
|
||||
for (const argname of Object.keys(argdef)) {
|
||||
if (argname in parsed) {
|
||||
result.push(parsed[argname])
|
||||
}
|
||||
}
|
||||
|
||||
// we're done
|
||||
return result
|
||||
}
|
||||
|
||||
// assuming:
|
||||
// - the first unknown argument is the name of the plugin
|
||||
// - plugins live in ../plugins/<plugin-name>/cli.js, relative to lrcli.js location
|
||||
// - only one plugin loaded per invocation, at least for now
|
||||
//
|
||||
// we *always* pass arguments to plugins as arrays of strings,
|
||||
// even if we only got one value
|
||||
|
||||
let main = async (args) => {
|
||||
|
||||
var parsed_args = parse(
|
||||
args,
|
||||
{
|
||||
default: {
|
||||
h: false,
|
||||
},
|
||||
stopEarly: true,
|
||||
boolean: [ "h" ],
|
||||
string: [],
|
||||
alias: {
|
||||
h: [ "help" ]
|
||||
},
|
||||
collect: [],
|
||||
negatable: [],
|
||||
// a function which is invoked with a command line parameter not defined
|
||||
// in the options configuration object. If the function returns false,
|
||||
// the unknown option is not added to parsedArgs.
|
||||
unknown: null
|
||||
}
|
||||
);
|
||||
|
||||
// no unknown parsed args? that means we have no plugin specified
|
||||
if (parsed_args._.length == 0) {
|
||||
console.log(getUsage())
|
||||
if (parsed_args.help) {
|
||||
return 0;
|
||||
} else {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// try loading the plugin
|
||||
let plugin
|
||||
try {
|
||||
plugin = await import(`../plugins/${parsed_args._[0]}/cli.js`);
|
||||
} catch (e) {
|
||||
// unable to load the plugin? bail with info
|
||||
console.log(`\n*** ${e} ***`)
|
||||
console.log(getUsage())
|
||||
return 2
|
||||
}
|
||||
|
||||
// if we only had exactly one unknown arg, we only have the plugin name
|
||||
// but no info from the user what to do with it
|
||||
// → print plugin usage and exit
|
||||
if (parsed_args._.length == 1) {
|
||||
if (!parsed_args.help) {
|
||||
console.log('\n*** No action specified for plugin ***')
|
||||
}
|
||||
console.log(getPluginUsage(plugin))
|
||||
if (parsed_args.help) {
|
||||
return 0;
|
||||
} else {
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
let action = parsed_args._[1]
|
||||
if ( ! (action in plugin.actions) ) {
|
||||
var exit_code = 0
|
||||
if (!['--help', '-h'].includes(action)) {
|
||||
console.log(`\n*** Action not supported: ${action} ***`)
|
||||
exit_code = 4
|
||||
}
|
||||
console.log(getPluginUsage(plugin))
|
||||
return exit_code
|
||||
}
|
||||
|
||||
if (['--help', '-h'].includes(parsed_args._[2])) {
|
||||
console.log(getPluginUsage(plugin))
|
||||
return 0
|
||||
}
|
||||
|
||||
var parsed_plugin_args = parsePluginActionArgs(
|
||||
// removing the plugin name and the method name
|
||||
parsed_args._.slice(2),
|
||||
// empty object in case arguments key does not exist
|
||||
plugin.actions[action].arguments || {}
|
||||
)
|
||||
|
||||
// not using console.log here because we want the *exact* output
|
||||
// without any extra ending newlines
|
||||
try {
|
||||
await Deno.stdout.write(
|
||||
new TextEncoder().encode(
|
||||
await plugin.actions[action].run(...parsed_plugin_args)
|
||||
)
|
||||
)
|
||||
return 0
|
||||
} catch (e) {
|
||||
console.log(`\n*** ${e} ***`)
|
||||
console.log(getPluginUsage(plugin))
|
||||
return 5
|
||||
}
|
||||
}
|
||||
|
||||
// export the main function
|
||||
export {
|
||||
main
|
||||
}
|
||||
|
||||
// run only if we're the main module
|
||||
if (import.meta.main) {
|
||||
Deno.exit(await main(Deno.args))
|
||||
}
|
|
@ -2,6 +2,8 @@
|
|||
"jest": {
|
||||
"collectCoverageFrom": [
|
||||
"**/*.js",
|
||||
"!**/plugins/**/cli.js",
|
||||
"!**/__denotests__/**",
|
||||
"!**/node_modules/**",
|
||||
"!**/lib/**"
|
||||
],
|
||||
|
@ -11,7 +13,7 @@
|
|||
"cobertura"
|
||||
],
|
||||
"testMatch": [
|
||||
"**/*.test.js"
|
||||
"**/__tests__/**/*.test.js"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -0,0 +1,182 @@
|
|||
/* ========================================================================= *\
|
||||
|* === basic-integrity: pre-configured subresource integrity for content === *|
|
||||
\* ========================================================================= */
|
||||
|
||||
/**
|
||||
* basic-integrity plugin's deploy/utility functions
|
||||
*
|
||||
* this code expects a Deno runtime:
|
||||
* https://deno.land/
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* helper function, converting binary to base64
|
||||
* this need not be extremely fast, since it will only be used on digests
|
||||
*
|
||||
* binary_data - data to convert to base64
|
||||
*/
|
||||
let binToBase64 = (binary_data) => {
|
||||
return btoa(
|
||||
(new Uint8Array(binary_data))
|
||||
.reduce((bin, byte)=>{
|
||||
return bin += String.fromCharCode(byte)
|
||||
}, '')
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* get integrity digests for a given path
|
||||
*
|
||||
* path - path to a file whose digest is to be generated
|
||||
* algos - array of SubtleCrypto.digest-compatible algorithm names
|
||||
*/
|
||||
let getFileIntegrity = async (path, algos) => {
|
||||
|
||||
var result = []
|
||||
var content = false
|
||||
|
||||
// open the file and get some info on it
|
||||
const file = await Deno.open(
|
||||
path,
|
||||
{ read: true }
|
||||
);
|
||||
const fileInfo = await file.stat();
|
||||
|
||||
// are we working with a file?
|
||||
if (fileInfo.isFile) {
|
||||
|
||||
// initialize
|
||||
content = new Uint8Array()
|
||||
var buf = new Uint8Array(1000);
|
||||
|
||||
// read the first batch
|
||||
var numread = file.readSync(buf);
|
||||
|
||||
// read the rest, if there is anything to read
|
||||
while (numread !== null) {
|
||||
//console.log(` +-- read: ${numread}`)
|
||||
//console.log(` +-- length: ${content.length}`)
|
||||
|
||||
// there has to be a better way...
|
||||
var new_content = new Uint8Array(content.length + numread);
|
||||
//console.log(` +-- new length: ${new_content.length}`)
|
||||
new_content.set(content)
|
||||
if (buf.length === numread) {
|
||||
new_content.set(buf, content.length)
|
||||
} else {
|
||||
new_content.set(buf.slice(0, numread), content.length)
|
||||
}
|
||||
content = new_content
|
||||
|
||||
// read some more
|
||||
numread = file.readSync(buf);
|
||||
}
|
||||
}
|
||||
|
||||
// putting this in a try-catch block as the file
|
||||
// is apparently being auto-closed?
|
||||
// https://issueantenna.com/repo/denoland/deno/issues/15442
|
||||
try {
|
||||
await file.close();
|
||||
} catch (BadResource) {}
|
||||
|
||||
// did we get any content?
|
||||
if (typeof content != "boolean") {
|
||||
for (const algo of algos) {
|
||||
//console.log(` +-- ${algo}`)
|
||||
var digest = algo.toLowerCase().replace('-', '') + '-' + binToBase64(await crypto.subtle.digest(algo, content))
|
||||
//console.log(digest)
|
||||
result.push(digest)
|
||||
}
|
||||
// no content means not a file
|
||||
} else {
|
||||
// so no result
|
||||
result = false
|
||||
}
|
||||
|
||||
// return the result
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* get integrity data for specified urls, using specified algorithms
|
||||
*
|
||||
* naming of algorithms as accepted by SubtleCrypto.digest():
|
||||
* https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest
|
||||
*
|
||||
* paths - array of strings, paths to individual pieces of content
|
||||
* algos - array of algorithms to use to calculate digests (default: "SHA-256")
|
||||
*/
|
||||
let getIntegrity = async (paths, algos=["SHA-256"], output="json") => {
|
||||
|
||||
// we need non-emtpy arrays of string in the arguments
|
||||
if (!Array.isArray(paths) || (paths.length == 0)) {
|
||||
throw new Error("Expected non-empty list of files to generate digests of.")
|
||||
}
|
||||
if (!Array.isArray(algos) || (algos.length == 0)) {
|
||||
throw new Error("Expected non-empty list of algorithms to use.")
|
||||
}
|
||||
if (!['json', 'text'].includes(output)) {
|
||||
throw new Error("Expected either 'json' or 'text' as output type to generate.")
|
||||
}
|
||||
|
||||
var result = {}
|
||||
for (const p of paths) {
|
||||
// filter-out stuff we are not interested in
|
||||
// like directories etc
|
||||
var r = await getFileIntegrity(p, algos)
|
||||
if (r !== false) {
|
||||
result[p] = r
|
||||
}
|
||||
}
|
||||
|
||||
if (output == 'json') {
|
||||
return JSON.stringify(result)
|
||||
} else {
|
||||
var text_result = ''
|
||||
for (const p of paths) {
|
||||
text_result += `${p}: ${result[p].join(' ')}\n`
|
||||
}
|
||||
return text_result
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// this never changes
|
||||
const pluginName = "basic-integrity"
|
||||
const pluginDescription = "Verifying subresource integrity for resources fetched by other plugins.\nCLI used to generate subresource integrity hashes for provided files."
|
||||
const pluginVersion = 'COMMIT_UNKNOWN'
|
||||
const pluginActions = {
|
||||
"get-integrity": {
|
||||
run: getIntegrity,
|
||||
description: "calculate subresource integrity hashes for provided files",
|
||||
arguments: {
|
||||
_: {
|
||||
name: "file",
|
||||
description: "paths of files to be processed"
|
||||
},
|
||||
algorithm: {
|
||||
description: "SubtleCrypto.digest-compatible algorithm names to use when calculating digests (default: \"SHA-256\")",
|
||||
collect: true,
|
||||
string: true,
|
||||
default: "SHA-256"
|
||||
},
|
||||
output: {
|
||||
description: "a string, defining output mode ('json' or 'text'; 'json' is default)",
|
||||
collect: false,
|
||||
string: true,
|
||||
default: "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
pluginName as name,
|
||||
pluginDescription as description,
|
||||
pluginVersion as version,
|
||||
pluginActions as actions
|
||||
}
|
|
@ -0,0 +1,347 @@
|
|||
/* ========================================================================= *\
|
||||
|* === Signed Integrity: content integrity using signed integrity data === *|
|
||||
\* ========================================================================= */
|
||||
|
||||
/**
|
||||
* signed-integrity plugin's deploy/utility functions
|
||||
*
|
||||
* this code expects a Deno runtime:
|
||||
* https://deno.land/
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* helper function, converting binary to base64
|
||||
* this need not be extremely fast, since it will only be used on digests
|
||||
*
|
||||
* binary_data - data to convert to base64
|
||||
*/
|
||||
let binToBase64 = (binary_data) => {
|
||||
return btoa(
|
||||
(new Uint8Array(binary_data))
|
||||
.reduce((bin, byte)=>{
|
||||
return bin += String.fromCharCode(byte)
|
||||
}, '')
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* generate an ECDSA P-384 keypair and export it as a JWK
|
||||
* https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey#json_web_key
|
||||
*/
|
||||
let genKeypair = async () => {
|
||||
let keypair = await crypto.subtle.generateKey({
|
||||
name: "ECDSA",
|
||||
namedCurve: "P-384"
|
||||
},
|
||||
true,
|
||||
["sign", "verify"]
|
||||
);
|
||||
let exported_keypair = {
|
||||
publicKey: await crypto.subtle.exportKey("jwk", keypair.publicKey),
|
||||
privateKey: await crypto.subtle.exportKey("jwk", keypair.privateKey)
|
||||
}
|
||||
return JSON.stringify(exported_keypair)
|
||||
}
|
||||
|
||||
/**
|
||||
* get a public key from a provided private key file
|
||||
*
|
||||
* keyfile - a path to a file containing the private key
|
||||
*/
|
||||
let getPubkey = async (keyfile) => {
|
||||
|
||||
// we only want to process one file
|
||||
if (Array.isArray(keyfile)) {
|
||||
keyfile = keyfile[0]
|
||||
}
|
||||
|
||||
// we need non-empty arguments
|
||||
if ((typeof keyfile !== "string") || (keyfile == "")) {
|
||||
throw new Error("No keyfile provided.")
|
||||
}
|
||||
|
||||
// load the key
|
||||
try {
|
||||
var keydata = JSON.parse(Deno.readTextFileSync(keyfile));
|
||||
} catch(e) {
|
||||
throw new Error(`Failed to load private key from '${keyfile}': ${e.message}`, {cause: e})
|
||||
}
|
||||
|
||||
|
||||
// the key can be either in a CryptoKeyPair structure, or directly in CryptoKey structure
|
||||
// standardize!
|
||||
if ("privateKey" in keydata) {
|
||||
keydata = keydata.privateKey
|
||||
}
|
||||
|
||||
// make the key public by deleting private parts and modifying key_ops
|
||||
// ref. https://stackoverflow.com/a/57571350
|
||||
delete keydata.d;
|
||||
keydata.key_ops = ['verify']
|
||||
|
||||
// import the key, thus making sure data is valid and makes sense
|
||||
let key = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
keydata,
|
||||
{
|
||||
name: 'ECDSA',
|
||||
namedCurve: 'P-384'
|
||||
},
|
||||
true,
|
||||
['verify']
|
||||
)
|
||||
|
||||
// export it again
|
||||
return JSON.stringify(await crypto.subtle.exportKey("jwk", key))
|
||||
}
|
||||
|
||||
/**
|
||||
* get integrity digests for a given path
|
||||
*
|
||||
* path - path to a file whose digest is to be generated
|
||||
* algos - array of SubtleCrypto.digest-compatible algorithm names
|
||||
*/
|
||||
let getFileIntegrity = async (path, algos) => {
|
||||
|
||||
var result = []
|
||||
var content = false
|
||||
|
||||
// open the file and get some info on it
|
||||
const file = await Deno.open(
|
||||
path,
|
||||
{ read: true }
|
||||
);
|
||||
const fileInfo = await file.stat();
|
||||
|
||||
// are we working with a file?
|
||||
if (fileInfo.isFile) {
|
||||
|
||||
// initialize
|
||||
content = new Uint8Array()
|
||||
var buf = new Uint8Array(1000);
|
||||
|
||||
// read the first batch
|
||||
var numread = file.readSync(buf);
|
||||
|
||||
// read the rest, if there is anything to read
|
||||
while (numread !== null) {
|
||||
//console.log(` +-- read: ${numread}`)
|
||||
//console.log(` +-- length: ${content.length}`)
|
||||
|
||||
// there has to be a better way...
|
||||
var new_content = new Uint8Array(content.length + numread);
|
||||
//console.log(` +-- new length: ${new_content.length}`)
|
||||
new_content.set(content)
|
||||
if (buf.length === numread) {
|
||||
new_content.set(buf, content.length)
|
||||
} else {
|
||||
new_content.set(buf.slice(0, numread), content.length)
|
||||
}
|
||||
content = new_content
|
||||
|
||||
// read some more
|
||||
numread = file.readSync(buf);
|
||||
}
|
||||
}
|
||||
|
||||
// putting this in a try-catch block as the file
|
||||
// is apparently being auto-closed?
|
||||
// https://issueantenna.com/repo/denoland/deno/issues/15442
|
||||
try {
|
||||
await file.close();
|
||||
} catch (BadResource) {}
|
||||
|
||||
// did we get any content?
|
||||
if (typeof content != "boolean") {
|
||||
for (const algo of algos) {
|
||||
//console.log(` +-- ${algo}`)
|
||||
var digest = algo.toLowerCase().replace('-', '') + '-' + binToBase64(await crypto.subtle.digest(algo, content))
|
||||
//console.log(digest)
|
||||
result.push(digest)
|
||||
}
|
||||
// no content means not a file
|
||||
} else {
|
||||
// so no result
|
||||
result = false
|
||||
}
|
||||
|
||||
// return the result
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* generate integrity files for provided paths
|
||||
*
|
||||
* paths - paths to files for which integrity files are to be generated
|
||||
* keyfile - path of the file containing the private key to use
|
||||
* algos - array of SubtleCrypto.digest-compatible hashing algorithms (default: ["SHA-256"])
|
||||
* output - whether to output the signed integrity data to "files", or as "text" or "json" (default)
|
||||
* extension - file extension to use when saving integrity files (default: ".integrity")
|
||||
*/
|
||||
let genSignedIntegrity = async (
|
||||
paths,
|
||||
keyfile,
|
||||
algos=["SHA-256"],
|
||||
output='json',
|
||||
extension='.integrity') => {
|
||||
|
||||
// we need non-empty arguments
|
||||
if (!Array.isArray(paths) || (paths.length == 0)) {
|
||||
throw new Error("Expected non-empty list of paths to process.")
|
||||
}
|
||||
if ((typeof keyfile !== "string") || (keyfile == "")) {
|
||||
throw new Error("No keyfile provided.")
|
||||
}
|
||||
if (!Array.isArray(algos) || (algos.length == 0)) {
|
||||
throw new Error("Expected non-empty list of algorithms to use.")
|
||||
}
|
||||
if (!['json', 'text', 'files'].includes(output)) {
|
||||
throw new Error("Expected 'json', 'text', or 'files' as output type.")
|
||||
}
|
||||
if ( (output == 'files') && ( (typeof extension !== "string") || (extension == "") ) ) {
|
||||
throw new Error("No extension provided.")
|
||||
}
|
||||
|
||||
// load the key
|
||||
try {
|
||||
var keydata = JSON.parse(Deno.readTextFileSync(keyfile));
|
||||
} catch(e) {
|
||||
throw new Error(`Failed to load private key from '${keyfile}': ${e.message}`, {cause: e})
|
||||
}
|
||||
|
||||
// the key can be either in a CryptoKeyPair structure, or directly in CryptoKey structure
|
||||
// standardize!
|
||||
if ("privateKey" in keydata) {
|
||||
keydata = keydata.privateKey
|
||||
}
|
||||
|
||||
// import the key, thus making sure data is valid and makes sense
|
||||
let privkey = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
keydata,
|
||||
{
|
||||
name: 'ECDSA',
|
||||
namedCurve: 'P-384'
|
||||
|
||||
},
|
||||
true,
|
||||
['sign']
|
||||
)
|
||||
|
||||
// initialize the result
|
||||
let result = {}
|
||||
|
||||
// do the thing for each path
|
||||
for (const path of paths) {
|
||||
|
||||
// get the integrity hash
|
||||
let integrity = await getFileIntegrity(path, algos)
|
||||
|
||||
// if integrity is false, the path is a directory or some such
|
||||
if (integrity == false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// JWT header
|
||||
let header = btoa('{"alg": "ES384"}').replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '')
|
||||
|
||||
// JWT payload -- the integrity hash
|
||||
// from MDN: "An integrity value may contain multiple hashes separated by whitespace.
|
||||
// A resource will be loaded if it matches one of those hashes."
|
||||
// https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity
|
||||
let payload = btoa(`{"integrity": "${integrity.join(' ')}"}`).replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '')
|
||||
|
||||
// get the signature for header + payload
|
||||
let data = new TextEncoder("utf-8").encode(header + '.' + payload)
|
||||
let signature = new Uint8Array(await crypto.subtle.sign(
|
||||
{
|
||||
name: "ECDSA",
|
||||
hash: {name: "SHA-384"}
|
||||
},
|
||||
privkey,
|
||||
data
|
||||
))
|
||||
// and prepare it for inclusion in the JWT
|
||||
signature = binToBase64(signature).replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '')
|
||||
|
||||
// put it all together
|
||||
let jwt = header + '.' + payload + '.' + signature
|
||||
|
||||
// do we want output to text or files
|
||||
result[path] = jwt
|
||||
if (output == 'files') {
|
||||
// write it out to {path}.extension
|
||||
Deno.writeTextFileSync(path + extension, jwt)
|
||||
}
|
||||
}
|
||||
|
||||
// return whatever we have to return
|
||||
if (output == 'json') {
|
||||
return JSON.stringify(result);
|
||||
} else if (output == 'text') {
|
||||
return Object.keys(result).map(p=>`${p}: ${result[p]}`).join('\n') + '\n'
|
||||
} else if (output == 'files') {
|
||||
return "created integrity files:\n" + Object.keys(result).map(p=>`- ${p}${extension}`).join('\n') + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
// this never changes
|
||||
const pluginName = "signed-integrity"
|
||||
const pluginDescription = "Fetching signed integrity data and using it to verify content.\nCLI used to generate subresource integrity tokens and save them in integrity files."
|
||||
const pluginVersion = 'COMMIT_UNKNOWN'
|
||||
const pluginActions = {
|
||||
"gen-keypair": {
|
||||
run: genKeypair,
|
||||
description: "generate a keypair and export it as a JSON Web Key"
|
||||
},
|
||||
"get-pubkey": {
|
||||
run: getPubkey,
|
||||
description: "print out a public key derived from the provided private key",
|
||||
arguments: {
|
||||
'_': {
|
||||
name: "keyfile",
|
||||
description: "file containing the private key in JSON Web Key format"
|
||||
}
|
||||
}
|
||||
},
|
||||
"gen-integrity": {
|
||||
run: genSignedIntegrity,
|
||||
description: "generate integrity files for given paths",
|
||||
arguments: {
|
||||
'_': {
|
||||
name: "file",
|
||||
description: "paths to generate signed integrity files for"
|
||||
},
|
||||
keyfile: {
|
||||
description: "path to the file containing a private key in JSON Web Key format",
|
||||
string: true
|
||||
},
|
||||
algorithm: {
|
||||
description: "SubtleCrypto.digest-compatible algorithm names to use when calculating digests (default: \"SHA-256\")",
|
||||
collect: true,
|
||||
string: true,
|
||||
default: "SHA-256"
|
||||
},
|
||||
output: {
|
||||
description: "output mode: 'files', 'text', or 'json'",
|
||||
default: 'json',
|
||||
string: true
|
||||
},
|
||||
extension: {
|
||||
description: "file extension to use when saving integrity files",
|
||||
default: '.integrity',
|
||||
string: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
pluginName as name,
|
||||
pluginDescription as description,
|
||||
pluginVersion as version,
|
||||
pluginActions as actions
|
||||
}
|
Ładowanie…
Reference in New Issue