src/utils/Encryption.js
import jose from "node-jose";
import fs from "fs";
import request from "superagent";
/**
* The Hyperwallet Encryption processor
*/
export default class Encryption {
/**
* Create a instance of the Encryption service
*
* @param {string} clientPrivateKeySetLocation - String that can be a URL or path to file with client JWK set
* @param {string} hyperwalletKeySetLocation - String that can be a URL or path to file with hyperwallet JWK set
* @param {string} encryptionAlgorithm - JWE encryption algorithm, by default value = RSA-OAEP-256
* @param {string} signAlgorithm - JWS signature algorithm, by default value = RS256
* @param {string} encryptionMethod - JWE encryption method, by default value = A256CBC-HS512
* @param {string} jwsExpirationMinutes - Minutes when JWS signature is valid
*/
constructor(clientPrivateKeySetLocation, hyperwalletKeySetLocation, encryptionAlgorithm = "RSA-OAEP-256",
signAlgorithm = "RS256", encryptionMethod = "A256CBC-HS512", jwsExpirationMinutes = 5) {
/**
* String that can be a URL or path to file with client JWK set
*
* @type {string}
* @protected
*/
this.clientPrivateKeySetLocation = clientPrivateKeySetLocation;
/**
* String that can be a URL or path to file with hyperwallet JWK set
*
* @type {string}
* @protected
*/
this.hyperwalletKeySetLocation = hyperwalletKeySetLocation;
/**
* Client KeyStore object
*
* @type {string}
* @protected
*/
this.clientKeyStore = null;
/**
* Hyperwallet KeyStore object
*
* @type {string}
* @protected
*/
this.hwKeyStore = null;
/**
* JWE encryption algorithm, by default value = RSA-OAEP-256
*
* @type {string}
* @protected
*/
this.encryptionAlgorithm = encryptionAlgorithm;
/**
* JWS signature algorithm, by default value = RS256
*
* @type {string}
* @protected
*/
this.signAlgorithm = signAlgorithm;
/**
* JWE encryption method, by default value = A256CBC-HS512
*
* @type {string}
* @protected
*/
this.encryptionMethod = encryptionMethod;
/**
* Minutes when JWS signature is valid, by default value = 5
*
* @type {number}
* @protected
*/
this.jwsExpirationMinutes = jwsExpirationMinutes;
}
/**
* Makes an encrypted request : 1) signs the request body; 2) encrypts payload after signature
*
* @param {string} body - The request body to be encrypted
*/
encrypt(body) {
return new Promise((resolve, reject) => {
const keyStorePromise = (this.clientKeyStore && this.hwKeyStore) ? Promise.resolve(this.keyStore) : this.createKeyStore();
keyStorePromise
.then(() => this.signBody(body))
.then(signedBody => this.encryptBody(signedBody))
.then(result => resolve(result))
.catch(error => reject(error));
});
}
/**
* Decrypts encrypted response : 1) decrypts the request body; 2) verifies the payload signature
*
* @param {string} body - The response body to be decrypted
*/
decrypt(body) {
return new Promise((resolve, reject) => {
const keyStorePromise = this.keyStore ? Promise.resolve(this.keyStore) : this.createKeyStore();
keyStorePromise
.then(() => this.decryptBody(body))
.then(decryptedBody => this.checkSignature(decryptedBody.plaintext))
.then(result => resolve(result))
.catch(error => reject(error));
});
}
/**
* Verify if response body has a valid signature
*
* @param {string} body - The response body to be verified
*/
checkSignature(body) {
return new Promise((resolve, reject) => {
const key = this.hwKeyStore.all({ alg: this.signAlgorithm })[0];
if (!key) {
reject(new Error(`JWK set doesn't contain key with algorithm = ${this.signAlgorithm}`));
return;
}
const options = {
handlers: {
exp: (jws) => {
if (Encryption.getCurrentTime() > jws.header.exp) {
reject(new Error("JWS signature has expired"));
}
},
},
};
jose.JWS.createVerify(key, options)
.verify(body.toString())
.then(result => resolve(result))
.catch(() => reject(new Error(`Failed to verify signature with key id = ${key.kid}`)));
});
}
/**
* Decrypts the response body
*
* @param {string} body - The response body to be decrypted
*/
decryptBody(body) {
return new Promise((resolve, reject) => {
const key = this.clientKeyStore.all({ alg: this.encryptionAlgorithm })[0];
if (!key) {
reject(new Error(`JWK set doesn't contain key with algorithm = ${this.encryptionAlgorithm}`));
return;
}
jose.JWE.createDecrypt(key)
.decrypt(body)
.then(result => resolve(result))
.catch(() => reject(new Error(`Failed to decrypt payload with key id = ${key.kid}`)));
});
}
/**
* Encrypts the request body
*
* @param {string} body - The request body to be encrypted
*/
encryptBody(body) {
return new Promise((resolve, reject) => {
const key = this.hwKeyStore.all({ alg: this.encryptionAlgorithm })[0];
if (!key) {
reject(new Error(`JWK set doesn't contain key with algorithm = ${this.encryptionAlgorithm}`));
return;
}
const encryptionHeader = {
format: "compact",
alg: key.alg,
enc: this.encryptionMethod,
kid: key.kid,
};
jose.JWE.createEncrypt(encryptionHeader, key)
.update(body)
.final()
.then(result => resolve(result))
.catch(() => reject(new Error(`Failed to encrypt payload with key id = ${key.kid}`)));
});
}
/**
* Makes signature for request body
*
* @param {string} body - The request body to be signed
*/
signBody(body) {
return new Promise((resolve, reject) => {
const key = this.clientKeyStore.all({ alg: this.signAlgorithm })[0];
if (!key) {
reject(new Error(`JWK set doesn't contain key with algorithm = ${this.signAlgorithm}`));
return;
}
const signHeader = {
format: "compact",
alg: key.alg,
fields: {
crit: ["exp"],
exp: this.getSignatureExpirationTime(),
kid: key.kid,
},
};
jose.JWS.createSign(signHeader, key)
.update(JSON.stringify(body), "utf8")
.final()
.then(result => resolve(result))
.catch(() => reject(new Error(`Failed to sign with key id = ${key.kid}`)));
});
}
/**
* Calculates signature expiration time in seconds ( by default expiration time = 5 minutes )
*/
getSignatureExpirationTime() {
const millisecondsInMinute = 60000;
const millisecondsInSecond = 1000;
const currentTime = new Date(new Date().getTime() + this.jwsExpirationMinutes * millisecondsInMinute).getTime();
return Math.round(currentTime / millisecondsInSecond);
}
/**
* Get current time in seconds
*/
static getCurrentTime() {
const millisecondsInSecond = 1000;
return Math.round(new Date().getTime() / millisecondsInSecond);
}
/**
* Creates 2 JWK key stores : 1) for client keys 2) for hyperwallet keys
*/
createKeyStore() {
return new Promise((resolve, reject) => {
Encryption.readKeySet(this.hyperwalletKeySetLocation)
.then(jwkSet => this.createKeyStoreFromJwkSet(this.hyperwalletKeySetLocation, jwkSet))
.then(() => Encryption.readKeySet(this.clientPrivateKeySetLocation))
.then(jwkSet => this.createKeyStoreFromJwkSet(this.clientPrivateKeySetLocation, jwkSet))
.then(result => resolve(result))
.catch(error => reject(error));
});
}
/**
* Converts JWK set in JSON format to JOSE key store format
*
* @param {string} jwkSetPath - The location of JWK set (can be URL string or path to file)
* @param {string} jwkSet - The JSON representation of JWK set, to be converted to keystore
*/
createKeyStoreFromJwkSet(jwkSetPath, jwkSet) {
return new Promise((resolve, reject) => {
jose.JWK.asKeyStore(jwkSet)
.then((result) => {
if (jwkSetPath === this.clientPrivateKeySetLocation) {
this.clientKeyStore = result;
} else {
this.hwKeyStore = result;
}
resolve(result);
})
.catch(() => reject(new Error("Failed to create keyStore from given jwkSet")));
});
}
/**
* Reads JWK set in JSON format either from given URL or path to local file
*
* @param {string} keySetPath - The location of JWK set (can be URL string or path to file)
*/
static readKeySet(keySetPath) {
return new Promise((resolve, reject) => {
if (fs.existsSync(keySetPath)) {
fs.readFile(keySetPath, { encoding: "utf-8" }, (err, keySetData) => {
if (!err) {
resolve(keySetData);
} else {
reject(new Error(err));
}
});
} else {
Encryption.checkUrlIsValid(keySetPath, (isValid) => {
if (isValid) {
request(keySetPath, (error, response) => {
if (!error) {
const responseBody = response.body && Object.keys(response.body).length !== 0 ? response.body : response.text;
resolve(responseBody);
}
});
} else {
reject(new Error(`Wrong JWK set location path = ${keySetPath}`));
}
});
}
});
}
/**
* Checks if an input string is a valid URL
*
* @param {string} url - The URL string to be verified
* @param {string} callback - The callback method to process the verification result of input url
*/
static checkUrlIsValid(url, callback) {
request(url, (error, response) => {
callback(!error && response.statusCode === 200);
});
}
/**
* Convert encrypted string to array of Buffer
*
* @param {string} encryptedBody - Encrypted body to be decoded
*/
static base64Decode(encryptedBody) {
const parts = encryptedBody.split(".");
const decodedParts = [];
parts.forEach((elem) => {
decodedParts.push(jose.util.base64url.decode(elem));
});
const decodedBody = {};
decodedBody.parts = decodedParts;
return decodedBody;
}
/**
* Convert array of Buffer to encrypted string
*
* @param {string} decodedBody - Array of Buffer to be decoded to encrypted string
*/
static base64Encode(decodedBody) {
const encodedParts = [];
decodedBody.parts.forEach((part) => {
encodedParts.push(jose.util.base64url.encode(Buffer.from(JSON.parse(JSON.stringify(part)).data)));
});
return encodedParts.join(".");
}
}