Merge branch 'wip-cli' into 'master'

Command Line Interface improvements

See merge request rysiekpl/libresilient!18
Michał "rysiek" Woźniak 2022-12-16 17:55:17 +00:00
commit 43e089f769
13 zmienionych plików z 1349 dodań i 1 usunięć

Wyświetl plik

@ -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

Wyświetl plik

@ -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)
});

Wyświetl plik

@ -0,0 +1,5 @@
{
"imports": {
"../plugins/simple-plugin/cli.js": "./mocks/simple-plugin.js"
}
}

Wyświetl plik

@ -0,0 +1 @@
hello world

Wyświetl plik

@ -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
}
}

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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')
});

Wyświetl plik

@ -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))
});

6
cli/README.md 100644
Wyświetl plik

@ -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.

260
cli/lrcli.js 100755
Wyświetl plik

@ -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))
}

Wyświetl plik

@ -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": {

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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
}