immuto-sdk
Version:
Immuto's JavaScript library for secure data management
1,293 lines (1,068 loc) • 63.4 kB
JavaScript
/* COPYRIGHT 2020 under the MIT License
* Immuto, Inc
*/
let IN_BROWSER = ("undefined" !== typeof window)
var Web3 = require('web3')
var sigUtil = require('eth-sig-util')
var NodeHTTP = require('xmlhttprequest').XMLHttpRequest // for backend compatability
var NodeForm = require('form-data') // for backend compatability
const axios = require('axios');
var crypto = require('crypto')
const NodeRSA = require('node-rsa');
const SYMMETRIC_SCHEME="aes-256-ctr"
const VALID_RECORD_TYPES=["basic", "editable"]
const DEV_ENDPOINT = "https://dev.immuto.io"
const PROD_ENDPIONT = "https://api.immuto.io"
function new_HTTP() {
if (IN_BROWSER) return new XMLHttpRequest()
else return new NodeHTTP()
}
function new_Form() { // for sending files with XMLHttpRequest
if (IN_BROWSER) return new FormData()
else return new NodeForm()
}
const DEFAULT_INIT_PARAMS = {
useSandbox: false,
host: PROD_ENDPIONT,
cachePassword: true,
}
/* debugHost as second argument remains for backwards compatibiility, but only
* for old internal testing that used localhost
* all public examples/usage have debugHost as dev.immuto.io (DEV_ENDPOINT)
**/
exports.init = function(options, debugHost) {
if (typeof options === "object") {
for (let param in DEFAULT_INIT_PARAMS) {
this[param] = (param in options) ? options[param] : DEFAULT_INIT_PARAMS[param]
}
if (this.useSandbox) {
this.host = options.host || DEV_ENDPOINT // allow specified dev host to override default for internal testing
}
} else if (options === true) { // for backwards compatibility when options was 'debug' flag
if (debugHost)
this.host = debugHost // safe to remove when debugHost no longer used internally
else
this.host = DEV_ENDPOINT // reasonable default
} else {
throw new Error("options argument must be an object") // as true === option now deprecated
}
this.web3 = new Web3(PROD_ENDPIONT) // Dummy provider because required on init now
// for web3 account management
this.salt = ""
this.encryptedKey = ""
// for API acess
this.authToken = ""
this.email = ""
this.userInfo = undefined // loaded on authenticate
this.pdr = undefined // starts to load on authenticate
if (IN_BROWSER) {
let ls = window.localStorage // preserve session across pages
if (ls.IMMUTO_authToken && ls.IMMUTO_email && ls.IMMUTO_salt && ls.IMMUTO_encryptedKey) {
this.salt = ls.IMMUTO_salt
this.encryptedKey = ls.IMMUTO_encryptedKey
this.authToken = ls.IMMUTO_authToken
this.email = ls.IMMUTO_email.toLowerCase()
this.password = ls.IMMUTO_password
this.userInfo = ls.IMMUTO_userInfo ? JSON.parse(ls.IMMUTO_userInfo) : undefined
}
}
this.utils = {
convert_unix_time: function(time_ms) {
let time_seconds = time_ms * 1000
var a = new Date(time_seconds);
var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
var year = a.getFullYear();
var month = months[a.getMonth()];
var date = a.getDate();
var hour = a.getHours();
var min = a.getMinutes() < 10 ? '0' + a.getMinutes() : a.getMinutes();
var sec = a.getSeconds() < 10 ? '0' + a.getSeconds() : a.getSeconds();
var time = date + ' ' + month + ' ' + year + ' ' + hour + ':' + min + ':' + sec ;
return time;
},
convert_js_time: function(time_js) {
return this.convert_unix_time(time_js / 1000)
},
emails_to_addresses: (emails) => {
return new Promise((resolve, reject) => {
var http = new_HTTP()
let sendstring = "emails=" + emails + "&authToken=" + this.authToken
http.open("POST", this.host + "/emails-to-addresses", true)
http.setRequestHeader("Content-Type", "application/x-www-form-urlencoded")
http.onreadystatechange = function() {
if (http.readyState === 4 && http.status === 200) {
try {
let addresses = JSON.parse(http.responseText)
resolve(addresses)
} catch (err) {
reject(err)
}
} else if (http.readyState === 4) {
reject(http.responseText)
}
}
http.send(sendstring)
})
},
addresses_to_emails: (history) => {
return new Promise((resolve, reject) => {
let addresses = []
for (let event of history) {
addresses.push(event.signer)
}
var http = new_HTTP()
let sendstring = "addresses=" + JSON.stringify(addresses) + "&authToken=" + this.authToken
http.open("POST", this.host + "/addresses-to-emails", true)
http.setRequestHeader("Content-Type", "application/x-www-form-urlencoded")
http.onreadystatechange = function() {
if (http.readyState === 4 && http.status === 200) {
try {
let addrToEmail = JSON.parse(http.responseText)
for (let i = 0; i < history.length; i++) {
history[i].email = addrToEmail[history[i].signer]
}
resolve(history)
} catch (err) {
reject(err)
}
} else if (http.readyState === 4) {
reject(http.responseText)
}
}
http.send(sendstring)
})
},
ecRecover: (message, v, r, s) => {
if (v === '28') {
v = Buffer.from('1c', 'hex')
} else {
v = Buffer.from('1b', 'hex')
}
r = Buffer.from(r.split('x')[1], 'hex')
s = Buffer.from(s.split('x')[1], 'hex')
let signature = sigUtil.concatSig(v, r, s)
let address = sigUtil.recoverPersonalSignature({data: message, sig: signature})
return this.web3.utils.toChecksumAddress(address)
},
parse_record_ID: (recordID) => {
if (!recordID) throw new Error("recordID is required");
if ("string" !== typeof recordID) throw new Error("recordID must be string");
if (recordID.length !== 48) {
throw new Error(`Invalid recordID: ${recordID}, reason: length not 48`)
}
return {
address: recordID.substring(0, 42),
shardHex: recordID.substring(42)
}
},
shard_to_URL: (shardIndex) => {
if (!shardIndex && shardIndex !== 0) {throw new Error("No prefix given")}
if (shardIndex === 0) { return "http://localhost:8443" }
if (this.host === PROD_ENDPIONT) {
return `https://prod${shardIndex}.immuto.io:8443`
}
return `https://shard${shardIndex}.immuto.io:8443`
},
shardIndex_to_hex: (shardIndex) => {
if (shardIndex !== 0 && !shardIndex) throw new Error("shardIndex is required")
const SHARD_LENGTH = 6
if (shardIndex > 16777215) { // ffffff
throw new Error(`shardIndex: ${shardIndex} exceeds width of ${SHARD_LENGTH}`)
}
if (shardIndex < 0) {throw new Error(`shardIndex must be a positive integer`)}
let hexString = Number(shardIndex).toString(16)
while (hexString.length < SHARD_LENGTH) {
hexString = "0" + hexString
}
return hexString
},
hex_to_shardIndex: (hexString) => {
if (!hexString) throw new Error("hexString is required")
if (typeof hexString !== "string") throw new Error(`hexString must be a string, got ${typeof hexString}`)
return parseInt(hexString, 16)
}
}
this.decrypt_account = function(password) {
password = password || this.password
if (!password) throw new Error("password is required")
try {
return this.web3.eth.accounts.decrypt(
this.encryptedKey,
password + this.salt
);
} catch(err) {
throw new Error("Incorrect password")
}
}
this.get_registration_token = function(address) {
return new Promise((resolve, reject) => {
if (!address) {
reject("User's address is required"); return;
}
var http = new_HTTP()
let sendstring = "address=" + address
http.open("POST", this.host + "/get-signature-data", true)
http.setRequestHeader("Content-Type", "application/x-www-form-urlencoded")
http.onreadystatechange = () => {
if (http.readyState === 4 && http.status === 200) {
resolve(http.responseText)
} else if (http.readyState === 4) {
reject(http.responseText)
}
}
http.send(sendstring)
})
}
this.register_user = function(email, password, orgToken) {
password = password || this.password
return new Promise((resolve, reject) => {
if (!email) {
reject("User email is required"); return;
}
if (!password) {
reject("User password is required"); return;
}
email = email.toLowerCase()
let salt = this.web3.utils.randomHex(32)
let account = this.web3.eth.accounts.create()
let address = account.address
let encryptedKey = this.web3.eth.accounts.encrypt(account.privateKey, password + salt);
this.get_registration_token(address)
.then((token) => {
let signature = account.sign(token)
var http = new_HTTP()
let sendstring = "email=" + email
sendstring += "&password=" + this.web3.utils.sha3(password)
sendstring += "&salt=" + salt
sendstring += "&address=" + address
sendstring += "&keyInfo=" + JSON.stringify(encryptedKey)
sendstring += "&signature=" + JSON.stringify(signature)
if (orgToken)
sendstring += "®istrationCode=" + orgToken
http.open("POST", this.host + "/submit-registration", true)
http.setRequestHeader("Content-Type", "application/x-www-form-urlencoded")
http.onreadystatechange = () => {
if (http.readyState === 4 && http.status === 204) {
resolve()
} else if (http.readyState === 4) {
reject(http.responseText)
}
}
http.send(sendstring)
})
.catch(err => reject(err))
})
}
// Best for backend use only, while authenticated as organization admin
this.permission_new_user = function(newUserEmail) {
return new Promise((resolve, reject) => {
if (!newUserEmail) {
reject("User email is required");
return;
}
let sendstring = "email=" + newUserEmail.toLowerCase()
sendstring += "&noEmail=true" // Causes API to respond with authToken rather than emailing user
sendstring += "&authToken=" + this.authToken // org admin authToken for permissioning new user registration
const http = new_HTTP()
http.open("POST", this.host + "/submit-org-member", true)
http.setRequestHeader("Content-Type", "application/x-www-form-urlencoded")
http.onreadystatechange = () => {
if (http.readyState === 4 && http.status === 200) {
resolve(http.responseText)
} else if (http.readyState === 4) {
reject(http.responseText)
}
}
http.send(sendstring)
})
}
this.submit_login = function(email, password) {
password = password || this.password
return new Promise((resolve, reject) => {
let http = new_HTTP()
let sendstring = "email=" + email
sendstring += "&password=" + this.web3.utils.sha3(password) // server does not see password
http.open("POST", this.host + "/submit-login", true)
http.setRequestHeader("Content-Type", "application/x-www-form-urlencoded")
http.onreadystatechange = () => {
if (http.readyState === 4 && http.status === 200) {
resolve(JSON.parse(http.responseText))
} else if (http.readyState === 4) {
reject(http.responseText)
}
}
http.send(sendstring)
})
}
this.prove_address = function(authToken, email, password) {
password = password || this.password
return new Promise((resolve, reject) => {
let account = {}
try {
account = this.decrypt_account(password)
} catch(err) {
reject(err); return;
}
let signature = account.sign(authToken)
let sendstring = "address=" + account.address
sendstring += "&signature=" + JSON.stringify(signature)
sendstring += "&authToken=" + authToken
sendstring += "&returnUserInfo=" + true
let http2 = new_HTTP()
http2.open("POST", this.host + "/prove-address", true)
http2.setRequestHeader("Content-Type", "application/x-www-form-urlencoded")
http2.onreadystatechange = () => {
if (http2.readyState === 4 && (http2.status === 204 || http2.status === 200)) {
if (http2.status === 204) {
resolve(undefined)
return
}
let userInfo = JSON.parse(http2.responseText)
if (userInfo.publicKey) { // already generated RSA keypair
resolve(userInfo)
return
}
this.generate_RSA_keypair(password)
.then(({pubKey, encryptedPrivateKey, rsaIv}) => {
userInfo.privateKey = encryptedPrivateKey
userInfo.publicKey = pubKey
userInfo.rsaIv = rsaIv
})
.catch(err => reject(err))
.finally(() => {
resolve(userInfo)
})
} else if (http2.readyState === 4) {
reject(http2.responseText)
}
}
http2.send(sendstring)
})
}
this.authenticate = async function(email, password) {
if (!email) { throw new Error("Email required for authentication") }
if (!password) { throw new Error("Password required for authentication") }
if (this.authToken) {
await this.deauthenticate()
}
email = email.toLowerCase()
const loginResponse = await this.submit_login(email, password)
if (loginResponse.result === "Your email or password are incorrect.") {
throw new Error("Incorrect password")
}
this.salt = loginResponse.salt
this.encryptedKey = loginResponse.encryptedKey
this.authToken = loginResponse.authToken
this.email = email
this.password = (this.cachePassword === true) ? password : ''
this.userInfo = await this.prove_address(this.authToken, email, password)
if (IN_BROWSER) {
window.localStorage.IMMUTO_salt = this.salt
window.localStorage.IMMUTO_encryptedKey = this.encryptedKey
window.localStorage.IMMUTO_authToken = this.authToken
window.localStorage.IMMUTO_email = this.email
window.localStorage.IMMUTO_password = this.password
window.localStorage.IMMUTO_userInfo = JSON.stringify(this.userInfo)
}
return this.authToken
}
this.reset_state = function() {
if (IN_BROWSER) {
window.localStorage.IMMUTO_authToken = ""
window.localStorage.IMMUTO_email = ""
window.localStorage.IMMUTO_salt = ""
window.localStorage.IMMUTO_encryptedKey = ""
window.localStorage.IMMUTO_password = ""
window.localStorage.IMMUTO_userInfo = ""
}
this.authToken = ""
this.email = ""
this.salt = ""
this.encryptedKey = ""
this.password = ""
this.userInfo = ""
this.pdr = undefined // todo: abort associated XHR if not yet resolved
}
this.deauthenticate = function() {
const authToken = this.authToken || (IN_BROWSER ? window.localStorage.IMMUTO_authToken : "")
this.reset_state()
if (!authToken) {
return new Promise(resolve => {resolve()})
}
return new Promise((resolve, reject) => {
let sendstring = `authToken=${authToken}`
this.reset_state()
var http = new_HTTP();
http.open("POST", this.host + "/logout-API", true);
http.setRequestHeader("Content-Type", "application/x-www-form-urlencoded")
http.onreadystatechange = () => {
if (http.readyState === 4 && http.status === 204) {
resolve()
} else if (http.readyState === 4) {
reject(http.responseText)
}
};
http.send(sendstring);
})
}
this.set_pdr = function(recordID) {
return new Promise((resolve, reject) => {
if (!recordID) { reject("No recordID given"); return;}
axios.post(this.host + '/set-pdr', {
recordID,
authToken: this.authToken,
})
.then(() => {
if (this.userInfo) {
this.userInfo.pdr = recordID
if (IN_BROWSER) {
window.localStorage.IMMUTO_userInfo = JSON.stringify(this.userInfo)
}
}
resolve()
})
.catch(err => {
reject(err)
});
})
}
this.load_pdr = async function(userInfo) {
userInfo = userInfo || this.userInfo
if (!this.userInfo) throw new Error("userInfo not yet loaded - has authenticate finished?")
const pdr = userInfo.pdr
if (!pdr) {
throw new Error("userInfo missing pdr field. Has user created pdr?")
}
const data = await this.download_file_data(pdr)
this.pdr = JSON.parse(data)
return this.pdr
}
this.get_pdr = function() {
if (!this.userInfo) throw new Error("userInfo not yet loaded - has authenticate finished?")
if (!this.pdr) {
return this.load_pdr()
}
return new Promise(resolve => resolve(this.pdr)) // already cached
}
this.store_pdr_entry = async function(entry) {
if (!entry) { throw new Error("No entry given"); }
entry = (typeof entry === "object") ? JSON.stringify(entry) : entry
const recordID = await this.upload_file_data(entry, "pdre")
await axios.post(this.host + '/store-pdr-entry', {
recordID,
authToken: this.authToken,
})
return recordID
}
this.get_pdr_entry = function(recordID) {
return new Promise((resolve, reject) => {
if (!recordID) { reject("No recordID given"); return;}
axios.get(this.host + '/pdr-entry', {
params: {
recordID,
authToken: this.authToken,
}
})
.then(entry => { resolve(entry.data) })
.catch(err => { reject(err) });
})
}
this.get_pdr_entries = function() {
return new Promise((resolve, reject) => {
axios.get(this.host + '/pdr-entries', {
params: {
authToken: this.authToken,
} })
.then(entries => { resolve(entries.data) })
.catch(err => { reject(err) });
})
}
this.update_encryption_info = function(encryptedKey, hashedPassword) {
return new Promise((resolve, reject) => {
const xhr = new_HTTP();
xhr.open("POST", this.host + "/reset-password", true);
xhr.setRequestHeader("Content-Type", "application/json")
xhr.onreadystatechange = () => {
if (xhr.readyState === 4 && xhr.status === 204) {
resolve()
} else if (xhr.readyState === 4) {
reject(xhr.responseText)
}
};
encryptedKey = JSON.stringify(encryptedKey)
const query = {
encryptedKey,
hashedPassword,
authToken: this.authToken
}
xhr.send(JSON.stringify(query));
})
}
this.reset_password = async function(oldPassword, newPassword) {
if (!oldPassword) throw new Error("oldPassword required")
if (!newPassword) throw new Error("newPassword required")
if (oldPassword === newPassword) throw new Error("oldPassword and newPassword must not match")
if (!this.authToken) throw new Error("User must be authenticated to reset password")
const uInfo = await this.get_user_info()
const account = this.decrypt_account(oldPassword)
const encryptedKey = account.encrypt(newPassword + uInfo.userSalt) // leave salt unchanged
const hashedPassword = this.web3.utils.sha3(newPassword)
await this.update_encryption_info(encryptedKey, hashedPassword)
// reauthenticate with updated info
await this.authenticate(this.email, newPassword)
}
// convenience method for signing an arbitrary string
// user address can be recovered from signature by utils.ecRecover
this.sign_string = function(string, password) {
password = password || this.password
if (!string) throw new Error("string is required for signing")
if (!password) throw new Error("password is required to sign string")
let account = this.decrypt_account(password) // throws error on bad password
return account.sign(string)
}
this.get_public_key = function(email) {
return new Promise((resolve, reject) => {
if (!email) {
reject("email is required")
return
}
let accessURL = this.host + "/public-key-for-user?email=" + email
accessURL += "&authToken=" + this.authToken
var http = new_HTTP();
http.open("GET", accessURL, true);
http.setRequestHeader('Accept', 'application/json, text/javascript');
http.onreadystatechange = function() {
if (http.readyState === 4 && http.status === 200) {
resolve(http.responseText)
} else if (http.readyState === 4) {
reject(http.responseText)
}
};
http.send()
})
}
this.decrypt_RSA_private_key = function(encryptedKey, rsaIv, password) {
password = password || this.password
if (!encryptedKey) {throw new Error("encryptedKey is required for decryption")}
if (!rsaIv) {throw new Error("iv is required for decryption")}
if (!password) {throw new Error("Password is required to decrypt RSA key")}
const decryptKey = this.generate_key_from_password(password)
const iv = this.string_to_iv(rsaIv)
return this.decrypt_string(encryptedKey, decryptKey, iv, 'base64')
}
this.generate_RSA_keypair = function(password) {
password = password || this.password
return new Promise((resolve, reject) => {
if (!password) {
reject("Password is required to generate RSA keypair")
return;
}
const rsaKey = NodeRSA({b: 2048})
const pubKey = rsaKey.exportKey('public')
const privKey = rsaKey.exportKey('private')
const encrypted = this.encrypt_string_with_password(privKey, password, 'base64')
const iv = this.iv_to_string(encrypted.iv)
const encryptedPrivateKey = encrypted.ciphertext
var form = new_Form()
form.append('publicKey', pubKey)
form.append('privateKey', encryptedPrivateKey)
form.append('rsaIv', iv)
form.append('authToken', this.authToken)
const url = this.host + "/set-rsa-keypair"
if (IN_BROWSER) {
var xhr = new_HTTP()
xhr.open("POST", url, true);
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 204) {
resolve({pubKey, privKey, rsaIv: iv, encryptedPrivateKey})
} else if (xhr.readyState === 4) {
reject(xhr.responseText)
}
};
xhr.send(form)
} else {
form.submit(url, function(err/*, res*/) {
if (err) {
reject(err)
return
}
resolve({pubKey, privKey, rsaIv: iv, encryptedPrivateKey})
})
}
})
}
this.generate_key_from_password = function(password) {
password = password || this.password
if (!password) {throw new Error("Password is required to generate key")}
const account = this.decrypt_account(password)
const hash = crypto.createHash("sha512")
hash.update(account.privateKey);
return hash.digest().slice(0, 32) // key for aes-256 must be 32 bytes
}
this.iv_to_string = function(iv) {
if (!iv) {throw new Error("No iv given")}
let str = ""
for (let i = 0; i < iv.length; i ++) {
str += iv[i] + ","
}
return str
}
this.string_to_iv = function(str) {
if (!str) {throw new Error("No string given")}
let ivStr = str.split(',')
let iv = new Uint8Array(16)
for (let i = 0; i < ivStr.length; i++) {
iv[i] = ivStr[i]
}
return iv
}
this.encrypt_with_publicKey = function(plaintext, publicKey) {
if (!plaintext) { throw new Error("No plaintext given") }
if (!publicKey) { throw new Error("No publicKey given") }
const key = new NodeRSA(publicKey)
return key.encrypt(plaintext, 'base64')
}
this.decrypt_with_privateKey = function(ciphertext, privateKey) {
if (!ciphertext) { throw new Error("No ciphertext given") }
if (!privateKey) { throw new Error("No privateKey given") }
const key = new NodeRSA(privateKey)
return key.decrypt(ciphertext, 'utf8')
}
this.encrypt_string_with_key = function(plaintext, key, encoding) {
if (!plaintext) { throw new Error("No plaintext given") }
if (!key) { throw new Error("No key given") }
encoding = encoding || 'binary'
const iv = crypto.randomBytes(16)
const cipher = crypto.createCipheriv(SYMMETRIC_SCHEME, key, iv)
let ciphertext = cipher.update(plaintext, 'binary', encoding);
ciphertext += cipher.final(encoding);
return {
ciphertext: ciphertext,
key: key,
iv: iv
}
}
this.encrypt_string_with_password = function(plaintext, password, encoding) {
password = password || this.password
if (!plaintext) {throw new Error("No plaintext given") }
if (!password) { throw new Error("No password given") }
let key = this.generate_key_from_password(password)
return this.encrypt_string_with_key(plaintext, key, encoding)
}
this.encrypt_string = function(plaintext, encoding) {
if (!plaintext) { throw new Error("No plaintext given") }
const key = crypto.randomBytes(32)
return this.encrypt_string_with_key(plaintext, key, encoding)
}
this.decrypt_string = function(ciphertext, key, iv, encoding) {
if (!ciphertext) { throw new Error("No ciphertext given") }
if (!key) { throw new Error("No key given") }
if (!iv) { throw new Error("No iv given") }
encoding = encoding || 'binary'
let decipher = crypto.createDecipheriv(SYMMETRIC_SCHEME, key, iv)
let decrypted = decipher.update(ciphertext, encoding, 'binary');
return decrypted + decipher.final('binary');
}
this.str2ab = function(str) {
if (!str) { throw new Error("No str given") }
var buf = new ArrayBuffer(str.length); // 2 bytes for each char
var bufView = new Uint8Array(buf);
for (var i=0, strLen=str.length; i < strLen; i++) {
bufView[i] = str.charCodeAt(i);
}
return bufView;
}
this.ab2str = function(buffer) {
if (!buffer) { throw new Error("No buffer given") }
let str = ""
for (let charCode of buffer) {
str += String.fromCharCode(charCode)
}
return str
}
this.get_file_content = function(file) {
return new Promise((resolve, reject) => {
if (!file) { reject("No file given"); return; }
var reader = new FileReader();
reader.onload = function() {
resolve(this.result)
}
reader.onerror = function(e) {
reject(e)
}
reader.readAsBinaryString(file);
})
}
// iv for aes256, optional for RSA
this.store_user_key_for_record = function(userEmail, recordID, encryptedKey, iv) {
return new Promise((resolve, reject) => {
if (!userEmail) { reject("No userEmail given"); return; }
if (!recordID) { reject("No recordID given"); return; }
if (!encryptedKey) { reject("No encryptedKey given"); return; }
try {
this.utils.parse_record_ID(recordID)
} catch(err) {
reject(err); return;
}
const url = this.host + "/set-key-for-record"
let form = new_Form()
form.append('userForKey', userEmail)
form.append('recordID', recordID)
form.append('encryptedKey', encodeURI(encryptedKey))
if (iv) form.append('iv', this.iv_to_string(iv))
form.append('authToken', this.authToken)
if (IN_BROWSER) {
let xhr = new_HTTP()
xhr.open("POST", url, true);
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 204) {
resolve()
} else if (xhr.readyState === 4) {
reject(xhr.responseText)
}
};
xhr.send(form);
} else {
form.submit(url, function(err/*, res*/) {
if (err) {
reject(err)
return
}
resolve()
})
}
})
}
this.upload_file_for_record = function(file, fileContent, recordID, password, projectID, handleProgress) {
password = password || this.password
return new Promise((resolve, reject) => {
if (!file) { reject("No file given"); return; }
if (!fileContent) { reject("No fileContent given"); return; }
if (!recordID) { reject("No recordID given"); return; }
if (!password) { reject("No password given"); return; }
let cipherInfo = this.encrypt_string(fileContent)
let encryptedKey = this.encrypt_string_with_password(
JSON.stringify({
key: cipherInfo.key,
iv: this.iv_to_string(cipherInfo.iv)
}), password, 'base64')
let encryptedFile = new Blob([this.str2ab(cipherInfo.ciphertext)], {type: file.type});
encryptedFile.lastModifiedDate = new Date();
encryptedFile.name = file.name;
let query = {
fileSize: encryptedFile.size,
fileName: file.name,
fileType: file.type,
recordID: recordID,
authToken: this.authToken
}
if (projectID) query.projectID = projectID
var xhr = new_HTTP()
let url = this.host + "/prepare-file-upload"
xhr.open("POST", url, true);
xhr.setRequestHeader("Content-Type", "application/json")
xhr.onreadystatechange = () => {
if (xhr.readyState === 4 && xhr.status === 200) {
let presignedPostData = JSON.parse(xhr.responseText)
const formData = new FormData();
Object.keys(presignedPostData.fields).forEach(key => {
formData.append(key, presignedPostData.fields[key]);
});
// Actual file has to be appended last
formData.append("file", encryptedFile);
const http = new_HTTP();
http.open("POST", presignedPostData.url, true);
http.onreadystatechange = () => {
if (http.readyState === 4 && http.status === 204) {
this.store_user_key_for_record(this.email, recordID, encryptedKey.ciphertext, encryptedKey.iv)
.then(() => resolve("done"))
.catch(err => reject(err))
} else if (http.readyState === 4){
reject(http.responseText)
}
};
http.upload.onprogress = function(e) {
let percentComplete = Math.ceil((e.loaded / e.total) * 100);
if (handleProgress) {
handleProgress(percentComplete)
}
};
http.send(formData);
} else if (xhr.readyState === 4) {
reject(xhr.responseText)
}
};
xhr.send(JSON.stringify(query));
})
}
this.upload_file = function(file, password, projectID, handleProgress) {
password = password || this.password
return new Promise((resolve, reject) => {
if (!file) { reject("No file given"); return; }
if (!password) { reject("No password given"); return; }
this.get_file_content(file)
.then((content) => {
this.create_data_management(content, file.recordName || file.name, "editable", password, "")
.then((recordID) => {
this.upload_file_for_record(file, content, recordID, password, projectID, handleProgress)
.then(() => resolve(recordID))
.catch(err => reject(err))
})
.catch(err => reject(err))
})
.catch(err => reject(err))
})
}
this.upload_file_data = function(fileContent, fileName, password, projectID, handleProgress) {
password = password || this.password
return new Promise((resolve, reject) => {
if (!fileContent) { reject("No fileContent given"); return; }
if (!fileName) { reject("No fileName given"); return; }
if (!password) { reject("No password given"); return; }
let file = {name: fileName, type: "text/plain"}
projectID = projectID || ''
this.create_data_management(fileContent, fileName, "editable", password, "")
.then((recordID) => {
this.upload_file_for_record(file, fileContent, recordID, password, projectID, handleProgress)
.then(() => resolve(recordID))
.catch(err => reject(err))
})
.catch(err => reject(err))
})
}
this.download_file_data = function(recordID, password, version) {
password = password || this.password
version = version || 0
return new Promise((resolve, reject) => {
if (!recordID) { reject("No recordID given"); return; }
if (!password) { reject("No password given"); return; }
try {
this.utils.parse_record_ID(recordID)
} catch(err) {
reject(err); return;
}
this.download_file_for_recordID(recordID, password, version, true)
.then(file => resolve(file.data))
.catch(err => reject(err))
})
}
this.search_records_by_hash = function(hash) {
return new Promise((resolve, reject) => {
if (!hash) { reject("No hash given"); return; }
let accessURL = this.host + "/records-for-hash?hash=" + hash
accessURL += "&authToken=" + this.authToken
var http = new_HTTP();
http.open("GET", accessURL, true);
http.setRequestHeader('Accept', 'application/json, text/javascript');
http.onreadystatechange = function() {
if (http.readyState === 4 && http.status === 200) {
let records = JSON.parse(http.responseText)
for (let i = 0; i < records.length; i++) {
records[i].recordID = records[i].contractAddr // so contractAddr remains internal language
}
resolve({records, hash})
} else if (http.readyState === 4) {
reject(http.responseText)
}
};
http.send()
})
}
this.search_records_by_content = function(fileContent) {
if (!fileContent) throw new Error("No fileContent given")
if (typeof fileContent !== "string") {
try {
fileContent = this.ab2str(fileContent)
} catch(err) {
throw new Error(`Failed to convert data to string: ${err}`)
}
}
return this.search_records_by_hash(this.web3.eth.accounts.hashMessage(fileContent))
}
this.search_records_by_query = function(query) {
return new Promise((resolve, reject) => {
if (!query) { reject("No query given"); return; }
let accessURL = this.host + '/search-user-data-contracts'
accessURL += "?authToken=" + this.authToken
var http = new_HTTP();
http.open("POST", accessURL, true);
http.setRequestHeader("Content-Type", "application/json")
http.onreadystatechange = function() {
if (http.readyState === 4 && http.status === 200) {
resolve(JSON.parse(http.responseText))
} else if (http.readyState === 4) {
reject(http.responseText)
}
};
http.send(JSON.stringify({
searchQuery: query
}))
})
}
this.get_info_for_recordID = function(recordID) {
return new Promise((resolve, reject) => {
if (!recordID) { reject("No recordID given"); return; }
try {
this.utils.parse_record_ID(recordID)
} catch(err) {
reject(err); return;
}
let accessURL = this.host + "/record-for-user?recordID=" + recordID
accessURL += "&authToken=" + this.authToken
var http = new_HTTP();
http.open("GET", accessURL, true);
http.setRequestHeader('Accept', 'application/json, text/javascript');
http.onreadystatechange = function() {
if (http.readyState === 4 && http.status === 200) {
resolve(JSON.parse(http.responseText))
} else if (http.readyState === 4) {
reject(http.responseText)
}
};
http.send()
})
}
this.build_full_URL = function(remoteURL) {
if (!remoteURL) { throw new Error("No remoteURL given") }
const S3_BUCKET = this.host === PROD_ENDPIONT ? "immuto-prod" : "immuto-sandbox"
if (remoteURL.includes(S3_BUCKET)) return remoteURL
return "https://" + S3_BUCKET + ".s3.amazonaws.com/readable/" + remoteURL
}
this.decrypt_fileKey_symmetric = function(encryptedKeyInfo, password) {
password = password || this.password
if (!encryptedKeyInfo) { throw new Error("No encryptedKeyInfo given") }
if (!password) { throw new Error("No password given") }
let decryptKey = this.generate_key_from_password(password)
let encryptedKey = encryptedKeyInfo.encryptedKey
let iv = this.string_to_iv(encryptedKeyInfo.iv)
let fileDecryptInfo = this.decrypt_string(encryptedKey, decryptKey, iv, 'base64')
let parsed = JSON.parse(fileDecryptInfo)
let fileDKey = parsed.key.data
let fileDiv = this.string_to_iv(parsed.iv)
return {
key: fileDKey,
iv: fileDiv
}
}
this.decrypt_fileKey_asymmetric = async function(encryptedKeyInfo, password) {
password = password || this.password
if (!encryptedKeyInfo) { throw new Error("No encryptedKeyInfo given") }
if (!password) { throw new Error("No password given") }
const encryptedKey = encryptedKeyInfo.encryptedKey
const userInfo = await this.get_user_info()
const privateKey = this.decrypt_RSA_private_key(userInfo.privateKey, userInfo.rsaIv, password)
let fileDecryptInfo = JSON.parse(this.decrypt_with_privateKey(encryptedKey, privateKey))
fileDecryptInfo.iv = this.string_to_iv(fileDecryptInfo.iv)
return fileDecryptInfo
}
this.key_to_string = function(keyInfo) {
if (!keyInfo) { throw new Error("No keyInfo given") }
if (!keyInfo.iv) { throw new Error("Given keyInfo has no iv field") }
keyInfo.iv = this.iv_to_string(keyInfo.iv)
return JSON.stringify(keyInfo)
}
this.get_encrypted_file = function(fileURL) {
return new Promise((resolve, reject) => {
if (!fileURL) { reject("No fileURL given"); return; }
let http = new_HTTP();
http.open("GET", this.build_full_URL(fileURL), true);
http.responseType = "arraybuffer"
http.onreadystatechange = () => {
if (http.readyState === 4 && http.status === 200) {
resolve(new Uint8Array(http.response))
} else if (http.readyState === 4) {
reject(http.responseText)
}
};
http.send()
})
}
this.download_file_for_record = async function(recordInfo, password, version, asPlaintext) {
password = password || this.password
if (!recordInfo) { throw new Error("No recordInfo given"); }
if (!recordInfo.files) { throw new Error("No files exist for record") }
if (!password) { throw new Error("No password given"); }
if (version < 0) { throw new Error("Version number must be non-negative integer") }
if (version >= recordInfo.files.length) {
throw new Error(`Invalid version number ${version} for record with ${recordInfo.files.length} versions`)
}
if (version !== 0)
version = version || recordInfo.files.length - 1 // default to recent
const fileInfo = recordInfo.files[version]
let fileURL = fileInfo.remoteURL
let encryptedKeyInfo = recordInfo[this.email.toLowerCase().replace(/\./g, " ")]
if (!encryptedKeyInfo) {
throw new Error(`User ${this.email} does not have key for decrypting file`);
}
let fileDecryptInfo = {}
if (encryptedKeyInfo.iv) { // for symmetric, personal key, non-RSA
fileDecryptInfo = this.decrypt_fileKey_symmetric(encryptedKeyInfo, password)
} else { // for RSA
fileDecryptInfo = await this.decrypt_fileKey_asymmetric(encryptedKeyInfo, password)
}
let fileDKey = fileDecryptInfo.key
let fileDiv = fileDecryptInfo.iv
let encryptedData = await this.get_encrypted_file(fileURL)
let plaintext = this.decrypt_string(encryptedData, fileDKey, fileDiv, 'base64') // this can be used for auto-verification (before splitting)
let file = {
type: fileInfo.type,
name: fileInfo.name,
data: asPlaintext ? plaintext : this.str2ab(plaintext)
}
return file
}
this.download_file_for_recordID = async function(recordID, password, version, asPlaintext) {
password = password || this.password
if (!recordID) { throw new Error("recordID is required"); }
if (!password) { throw new Error("password is required"); }
this.utils.parse_record_ID(recordID) // throws error on bad record
let recordInfo = await this.get_info_for_recordID(recordID)
let file = await this.download_file_for_record(recordInfo, password, version, asPlaintext)
return file
}
this.get_user_info = function() {
return new Promise((resolve, reject) => {
var xhr = new_HTTP();
let url = this.host + '/get-user-info?authToken=' + this.authToken
xhr.open("GET", url, true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded")
xhr.onreadystatechange = () => {
if (xhr.readyState === 4 && xhr.status === 200) {
resolve(JSON.parse(xhr.responseText))
} else if (xhr.readyState === 4) {
reject(xhr.responseText)
}
};
xhr.send();
})
}
this.share_record_access = function(recordID, shareEmail) {
return new Promise((resolve, reject) => {
if (!recordID) { reject("No recordID given"); return; }
if (!shareEmail) { reject("No shareEmail given"); return; }
try {
this.utils.parse_record_ID(recordID)
} catch(err) {
reject(err); return;
}
var xhr = new_HTTP();
xhr.open("POST", this.host + '/share-record', true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded")
xhr.onreadystatechange = () => {
if (xhr.readyState === 4 && xhr.status === 204) {
resolve(xhr.responseText)
} else if (xhr.readyState === 4) {
reject(xhr.responseText)
}
};
let sendstring = 'recordID=' + recordID
sendstring += '&shareEmail=' + shareEmail
sendstring += '&authToken=' + this.authToken
xhr.send(sendstring);
})
}
this.share_record = async function(recordID, shareEmail, password) {
password = password || this.password
if (!recordID) throw new Error("RecordID is required")
if (!shareEmail) throw new Error("shareEmail is required")
if (!password) throw new Error("passowrd is required"