2022-12-10 22:02:18 +00:00
/ * = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = * \
| * === Signed Integrity : content integrity using signed integrity data === * |
\ * === === === === === === === === === === === === === === === === === === === === === === === === = * /
/ * *
* signed - integrity plugin ' s deploy / utility functions
*
* this code expects a Deno runtime :
* https : //deno.land/
* /
2022-12-10 23:49:10 +00:00
/ * *
* 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 )
} , '' )
)
}
2022-12-10 22:02:18 +00:00
/ * *
* 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 )
}
2022-12-10 23:07:55 +00:00
/ * *
2022-12-10 23:49:10 +00:00
* get a public key from a provided private key file
2022-12-10 23:07:55 +00:00
*
* 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 ]
}
2022-12-11 15:00:14 +00:00
// 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 } )
}
2022-12-10 23:49:10 +00:00
// the key can be either in a CryptoKeyPair structure, or directly in CryptoKey structure
2022-12-10 23:07:55 +00:00
// 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
2022-12-10 23:49:10 +00:00
let key = await crypto . subtle . importKey (
"jwk" ,
keydata ,
{
name : 'ECDSA' ,
namedCurve : 'P-384'
} ,
true ,
[ 'verify' ]
)
2022-12-10 23:07:55 +00:00
// export it again
return JSON . stringify ( await crypto . subtle . exportKey ( "jwk" , key ) )
}
2022-12-10 22:02:18 +00:00
2022-12-10 23:49:10 +00:00
/ * *
* 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 = [ ]
// 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 ) {
//console.log(`+-- reading: ${path}`)
// initialize
var 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 ) ;
}
//console.log(' +-- done.')
//console.log('+-- calculating digests')
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 )
}
//console.log(`+-- file done: ${path}`)
// we are not working with a file
} else {
result = false ;
}
// 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 ) { }
// 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
2022-12-11 14:15:50 +00:00
* algos - array of SubtleCrypto . digest - compatible hashing algorithms ( default : [ "SHA-256" ] )
2022-12-11 14:38:57 +00:00
* output - whether to output the signed integrity data to "files" , or as "text" or "json" ( default )
2022-12-10 23:49:10 +00:00
* extension - file extension to use when saving integrity files ( default : ".integrity" )
* /
2022-12-11 14:15:50 +00:00
let genSignedIntegrity = async (
paths ,
keyfile ,
algos = [ "SHA-256" ] ,
2022-12-11 14:38:57 +00:00
output = 'json' ,
2022-12-11 14:15:50 +00:00
extension = '.integrity' ) => {
2022-12-10 23:49:10 +00:00
2022-12-11 15:00:14 +00:00
// 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." )
}
2022-12-10 23:49:10 +00:00
// load the key
2022-12-11 15:00:14 +00:00
try {
var keydata = JSON . parse ( Deno . readTextFileSync ( keyfile ) ) ;
} catch ( e ) {
throw new Error ( ` Failed to load private key from ' ${ keyfile } ': ${ e . message } ` , { cause : e } )
}
2022-12-10 23:49:10 +00:00
// 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' ]
)
2022-12-11 03:06:47 +00:00
// initialize the result
2022-12-11 14:38:57 +00:00
let result = { }
2022-12-11 03:06:47 +00:00
// do the thing for each path
for ( const path of paths ) {
// get the integrity hash
2022-12-11 14:15:50 +00:00
let integrity = await getFileIntegrity ( path , algos )
2022-12-11 03:06:47 +00:00
// 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
2022-12-11 14:15:50 +00:00
// 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 , '' )
2022-12-11 03:06:47 +00:00
// 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
2022-12-11 14:15:50 +00:00
// do we want output to text or files
2022-12-11 14:38:57 +00:00
result [ path ] = jwt
if ( output == 'files' ) {
2022-12-11 03:06:47 +00:00
// write it out to {path}.extension
Deno . writeTextFileSync ( path + extension , jwt )
}
}
// return whatever we have to return
2022-12-11 14:38:57 +00:00
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"
}
2022-12-10 23:49:10 +00:00
}
2022-12-10 22:02:18 +00:00
// 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"
2022-12-10 23:07:55 +00:00
} ,
"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"
}
}
2022-12-10 23:49:10 +00:00
} ,
"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
} ,
2022-12-11 14:15:50 +00:00
algorithm : {
description : "SubtleCrypto.digest-compatible algorithm names to use when calculating digests (default: \"SHA-256\")" ,
collect : true ,
string : true ,
default : "SHA-256"
} ,
2022-12-11 03:06:47 +00:00
output : {
2022-12-11 14:38:57 +00:00
description : "output mode: 'files', 'text', or 'json'" ,
default : 'json' ,
2022-12-11 03:06:47 +00:00
string : true
} ,
2022-12-10 23:49:10 +00:00
extension : {
description : "file extension to use when saving integrity files" ,
default : '.integrity' ,
string : true
}
}
2022-12-10 22:02:18 +00:00
}
}
export {
pluginName as name ,
pluginDescription as description ,
pluginVersion as version ,
pluginActions as actions
}