kopia lustrzana https://gitlab.com/rysiekpl/libresilient
344 wiersze
11 KiB
JavaScript
344 wiersze
11 KiB
JavaScript
/* ========================================================================= *\
|
|
|* === 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) {
|
|
const reader = file.readable.getReader();
|
|
const chunks = [];
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
break;
|
|
}
|
|
chunks.push(value);
|
|
}
|
|
|
|
let length = 0;
|
|
for (const chunk of chunks) {
|
|
length += chunk.length;
|
|
}
|
|
|
|
content = new Uint8Array(length);
|
|
|
|
let index = 0;
|
|
for (const chunk of chunks) {
|
|
content.set(chunk, index);
|
|
index += chunk.length;
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|