@hutiwephy/ape
Version:
Authenticate and Encrypt HTTP with User Credentials (Not TLS-SRP).
525 lines (469 loc) • 16.7 kB
JavaScript
const CryptoJS = require("crypto-js");
const jwt = require("./jwt.js");
const {WordArray2ArrayBuffer} = require("./extensions.js");
class Session {
static InvalidJWTPropertyOverride = class extends Error {
message = "Attempted to override a forbiden property or provided an invalid value";
}
static InvalidDateError = class extends Error {
message = "JWT Date is outside of allowed range";
};
static AuthParserException = class extends Error {
message = "Failed to parse the provided Authorization string";
};
static InvalidEncryptionAlgorithm = class extends Error {
message = "The Provided encryption algorithm is unsupported";
};
static InvalidSigningAlgorithm = class extends Error {
message = "The Provided signing algorithm is unsupported";
};
static InvalidSaltLength = class extends Error {
message = "The Provided salt is not of the correct size";
};
static InvalidIVLength = class extends Error {
message = "The Provided iv is not of the correct size";
};
static FailedVerification = class extends Error {
message = "Failed to verify";
};
static MissingVerification = class extends Error {
message = "Cannot encrypt and/or decrypt without verified session";
}
/**
* @type {null|({
* header: {
* alg: string,
* enc: string,
* typ: string,
* iv: string,
* },
* payload: {
* sub: string
* iat: number,
* salt: string,
* },
* signed: string,
* })}
*/
#jwt = null;
/** @type {null|CryptoJS.lib.WordArray} */
#salt = null;
/** @type {null|CryptoJS.lib.WordArray} */
#iv = null;
/** @type {null|CryptoJS.lib.WordArray} */
#key = null;
#validAlgorithms = {
sign: [
"HS256",
"HS384",
"HS512"
],
encrypt: {
"AES-128-CBC": {
length: 128,
mode: CryptoJS.mode.CBC,
},
"AES-128-CFB": {
length: 128,
mode: CryptoJS.mode.CFB,
},
"AES-128-CTR": {
length: 128,
mode: CryptoJS.mode.CTR,
},
"AES-128-ECB": {
length: 128,
mode: CryptoJS.mode.ECB,
},
"AES-192-CBC": {
length: 192,
mode: CryptoJS.mode.CBC,
},
"AES-192-CFB": {
length: 192,
mode: CryptoJS.mode.CFB,
},
"AES-192-CTR": {
length: 192,
mode: CryptoJS.mode.CTR,
},
"AES-192-ECB": {
length: 192,
mode: CryptoJS.mode.ECB,
},
"AES-256-CBC": {
length: 256,
mode: CryptoJS.mode.CBC,
},
"AES-256-CFB": {
length: 256,
mode: CryptoJS.mode.CFB,
},
"AES-256-CTR": {
length: 256,
mode: CryptoJS.mode.CTR,
},
"AES-256-ECB": {
length: 256,
mode: CryptoJS.mode.ECB,
},
},
};
/**
* @overload
* Build a Session from a HTTP Authentication String (JWT)
*
* @param {string} token
*
*
* @overload
* Build a Session from a client id and secret
*
* @param {string|ArrayBufferLike} id
* @param {string|ArrayBufferLike} secret
*
*
* @overload
* Build a Session from a client id and secret with custom jwt parameters
*
* @param {string|ArrayBufferLike} id
* @param {string|ArrayBufferLike} secret
* @param {({
* header?: {
* alg?: string,
* enc?: string,
* iv?: string,
* },
* payload?: {
* iss?: string,
* aud?: string,
* exp?: string,
* nbf?: string,
* iat?: string,
* jti?: string,
* salt?: string,
* },
* })} options
*
*/
constructor(){
var token = null;
/** @type {({id: string|ArrayBufferLike|CryptoJS.lib.WordArray, secret: string|ArrayBufferLike|CryptoJS.lib.WordArray})|null} */
var client = null;
var options = null;
var length = 256;
switch(arguments.length){
case 1:
token = arguments[0];
break;
case 2:
client = {
id: arguments[0],
secret: arguments[1],
};
break;
case 3:
client = {
id: arguments[0],
secret: arguments[1],
};
options = arguments[2];
break;
default:
throw RangeError("Invalid argument count");
}
if(token !== null){
if(typeof token != "string"){
throw new TypeError("token parameter expected to be string");
}
// Parse JWT
var tk = null;
try{
tk = jwt.decode(token);
}catch(err){
throw new Session.AuthParserException();
}
// typ
if(tk.header.typ !== "JWT"){
throw new Session.AuthParserException();
}
// alg
if(typeof tk.header.alg != "string" || !(this.#validAlgorithms.sign.includes(tk.header.alg))){
throw new Session.AuthParserException();
}
// enc
if(typeof tk.header.enc != "string" || !(Object.keys(this.#validAlgorithms.encrypt).includes(tk.header.enc))){
throw new Session.AuthParserException();
}
length = this.#validAlgorithms.encrypt[tk.header.enc].length;
// iv
if(typeof tk.header.iv == "string"){
var tmpiv = CryptoJS.enc.Base64url.parse(tk.header.iv);
if(tmpiv.sigBytes != 16){
throw new Session.InvalidIVLength();
}
this.#iv = tmpiv;
}else{
throw new Session.AuthParserException();
}
// sub
if(typeof tk.payload.sub == "string"){
CryptoJS.enc.Base64url.parse(tk.payload.sub);
}else{
throw new Session.AuthParserException();
}
// salt
if(typeof tk.payload.salt == "string"){
var tmpsalt = CryptoJS.enc.Base64url.parse(tk.payload.salt);
if(tmpsalt.sigBytes != length){
throw new Session.InvalidSaltLength();
}
this.#salt = tmpsalt;
}else{
throw new Session.AuthParserException();
}
// iat
if(typeof tk.payload.iat != "number"){
throw new Session.AuthParserException();
}
// Build JWT
this.#jwt = {
header: tk.header,
payload: tk.payload,
};
// Save signed JWT
this.#jwt.signed = token;
}else if(client !== null){
// Parse options
// Defaults
this.#salt = CryptoJS.lib.WordArray.random(length);
this.#iv = CryptoJS.lib.WordArray.random(16);
var default_options = {
header: {
alg: "HS256",
enc: "AES-256-ECB",
typ: "JWT",
iv: this.#iv.toString(CryptoJS.enc.Base64url),
},
payload: {
iat: Date.now(),
salt: this.#salt.toString(CryptoJS.enc.Base64url),
}
};
if(options == null){
options = default_options;
}
// Header
if(typeof options["header"] != "object"){
options.header = default_options.header;
}else{
// typ
if(options.header.typ !== "JWT"){
throw new Session.InvalidJWTPropertyOverride();
}
options.header.typ = default_options.header.typ;
// alg
if(typeof options.header.alg != "string"){
options.header.alg = default_options.header.alg;
}else if(!(this.#validAlgorithms.sign.includes(options.header.alg))){
throw new Session.InvalidSigningAlgorithm();
}
// enc
if(typeof options.header.enc != "string"){
options.header.enc = default_options.header.enc;
}else if(!(Object.keys(this.#validAlgorithms.encrypt).includes(options.header.enc))){
throw new Session.InvalidEncryptionAlgorithm();
}
length = this.#validAlgorithms.encrypt[options.header.enc].length;
// iv
if(typeof options.header.iv != "string"){
options.header.iv = default_options.header.iv;
}else{
var tmpiv = CryptoJS.enc.Base64url.parse(options.header.iv);
if(tmpiv.sigBytes != 16){
throw new Session.InvalidIVLength();
}
this.#iv = tmpiv;
}
}
// Payload
if(typeof options["payload"] != "object"){
options.payload = default_options.payload;
}else{
// sub
if(options.payload.sub !== undefined){
throw new Session.InvalidJWTPropertyOverride();
}
// iat
if(typeof options.payload.iat != "number"){
options.payload.iat = default_options.payload.iat;
}
// salt
if(typeof options.payload.salt != "string"){
options.payload.salt = default_options.payload.salt;
}else{
var tmpsalt = CryptoJS.enc.Base64url.parse(options.payload.salt);
if(tmpsalt.sigBytes != length){
throw new Session.InvalidSaltLength();
}
this.#salt = tmpsalt;
}
}
// Validate Client ID
if(typeof client.id == "string"){
client.id = CryptoJS.enc.Base64.parse(client.id);
}else if(client.id instanceof ArrayBuffer || ArrayBuffer.isView(client.id)){
client.id = CryptoJS.lib.WordArray.create(client.id);
}else{
throw new TypeError("id parameter expected to be base64 string or buffer");
}
options.payload.sub = CryptoJS.enc.Base64url.stringify(client.id);
// Validate Client Secret
if(typeof client.secret == "string"){
client.secret = CryptoJS.enc.Base64.parse(client.secret);
}else if(client.secret instanceof ArrayBuffer || ArrayBuffer.isView(client.secret)){
client.secret = CryptoJS.lib.WordArray.create(client.secret);
}else{
throw new TypeError("secret parameter expected to be base64 string or buffer");
}
// Build JWT
this.#jwt = {
header: options.header,
payload: options.payload,
};
// Generate Key
this.#key = this.#generateKey(client.secret);
// Sign JWT
this.#jwt.signed = jwt.sign(this.#jwt.header, this.#jwt.payload, this.#key);
}else{
throw new Error("Unknown Malfunction");
}
}
get jwt(){
var tmp = (function(){
return {
header: this.#jwt.header,
payload: this.#jwt.payload,
};
})();
Object.freeze(tmp);
return tmp;
}
get clientId(){
return WordArray2ArrayBuffer(CryptoJS.enc.Base64url.parse(this.#jwt.payload.sub));
}
get token(){
return (this.#key !== null)? this.#jwt.signed : null;
}
/**
*
* @param {CryptoJS.lib.WordArray} secret
*
* @returns {CryptoJS.lib.WordArray}
*/
#generateKey(secret){
secret = secret.concat(CryptoJS.enc.Utf8.parse(`${this.#jwt.payload.iat}`));
return CryptoJS.PBKDF2(secret, this.#salt, {
keySize: this.#validAlgorithms.encrypt[this.#jwt.header.enc].length/32,
});
}
/**
* Attempts verification with secret if key is already populated will throw error
*
* @param {null|string|ArrayBufferLike} secret
* @param {({
* critical?: Array.<string>,
* date?: number,
* maxlifetime?: number,
* tolerance?: number,
* validators?: {
* issuer?: Array.<string>|function(string):boolean,
* audience?: Array.<string>|function(string):boolean,
* jwtid?: Array.<string>|function(string):boolean,
* },
* })} options
*
* @returns {boolean}
*/
verify(secret, options={}){
if(secret == null){ return false; }
var key = this.#key;
if(key == null){
// Validate Client Secret
if(typeof secret == "string"){
secret = CryptoJS.enc.Base64.parse(secret);
}else if(secret instanceof ArrayBuffer || ArrayBuffer.isView(secret)){
secret = CryptoJS.lib.WordArray.create(secret);
}else{
throw new TypeError("secret parameter expected to be base64 string or buffer");
}
key = this.#generateKey(secret);
}
if(options?.validators?.subject != null){
options.validators.subject = undefined;
}
var result = jwt.verify(this.#jwt.signed, key, options);
if(this.#key == null && result){
this.#key = key;
}
return result;
}
/**
* If key is populated it will encode a chunk of data into a body chunk
*
* @param {string|ArrayBufferLike} chunk
* @returns {string}
*/
encode(chunk){
if(this.#key === null){
throw new Session.MissingVerification();
}
if(typeof chunk == "string"){
chunk = CryptoJS.enc.Utf8.parse(chunk);
}else if(chunk instanceof ArrayBuffer || ArrayBuffer.isView(chunk)){
chunk = CryptoJS.lib.WordArray.create(chunk);
}else{
throw new TypeError("chunk parameter expected to be base64 string or buffer");
}
var tmp = CryptoJS.AES.encrypt(chunk, this.#key, {
iv: this.#iv,
mode: this.#validAlgorithms.encrypt[this.#jwt.header.enc].mode,
padding: CryptoJS.pad.Pkcs7,
});
return `${CryptoJS.enc.Base64url.stringify(tmp.ciphertext)}.`;
}
/**
* If key is populated it will decode a body chunk into a chunk of data
*
* @param {string} chunk
* @returns {Buffer|Uint8Array}
*/
decode(chunk){
if(this.#key === null){
throw new Session.MissingVerification();
}
chunk = CryptoJS.enc.Base64url.parse(chunk.replace(".", ""));
var tmp = CryptoJS.AES.decrypt({ciphertext: chunk}, this.#key, {
iv: this.#iv,
mode: this.#validAlgorithms.encrypt[this.#jwt.header.enc].mode,
padding: CryptoJS.pad.Pkcs7,
});
return WordArray2ArrayBuffer(tmp);
}
/**
* Parse a Request or Response body
*
* @param {string} body
* @returns {Buffer|Uint8Array}
*/
parse(body){
if(body == null){ return (typeof window != "undefined")? new Uint8Array(0) : Buffer.alloc(0); }
var echunks = body.split(".");
var dchunks = [];
for(var i=0; i<echunks.length; i++){
dchunks.push(this.decode(echunks[i]));
}
return (typeof window != "undefined")? Uint8Array.concat(dchunks) : Buffer.concat(dchunks);
}
}
module.exports = Session;