diff --git a/Dockerfile b/Dockerfile index a072a900e07bee18ab81b1e4794745777199af8c..d088fec85bfda919e93a4b1811f27ac3c38fb0f8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM registry.vereign.com/docker/vcl-build-base:buster as builder +FROM registry.vereign.com/docker/vcl-build-base:golang1.14.1 as builder ARG GITLAB_LOGIN ARG GITLAB_PASSWORD @@ -11,5 +11,7 @@ RUN git config --global url."https://$GITLAB_LOGIN:$GITLAB_PASSWORD@code.vereign FROM registry.vereign.com/docker/go-runtime:master COPY --from=builder /go/src/code.vereign.com/code/vcl/javascript/dist /srv/dist +COPY --from=builder /go/src/code.vereign.com/code/vcl/Gopkg.lock /srv/dist/ + ENTRYPOINT ["/bin/cp","-a","/srv/dist/.","/srv/target"] diff --git a/javascript/src/constants/secrets.js b/javascript/src/constants/secrets.js new file mode 100644 index 0000000000000000000000000000000000000000..41b9ef5ee76b93cffcc5141a9534e013ff065dbb --- /dev/null +++ b/javascript/src/constants/secrets.js @@ -0,0 +1,2 @@ +export const RECOMMENDED_TRUSTEES = 3; +export const THRESHOLD = 2; diff --git a/javascript/src/iframe/viamapi-iframe.js b/javascript/src/iframe/viamapi-iframe.js index 06421ddc10d4f098559a4e891b9a51e509d1e9f6..96c943b8024053af72e7ae102a1bf42c7cb5a302 100644 --- a/javascript/src/iframe/viamapi-iframe.js +++ b/javascript/src/iframe/viamapi-iframe.js @@ -41,6 +41,12 @@ import { STATUS_USER_BLOCKED } from "../constants/statuses"; import generateQrCode from "../utilities/generateQrCode"; +import { + generateRecoveryKey, + getRecoveryKeyShares, + checkRecoveryKeyCombine, + encryptShare +} from "../utilities/secrets"; const penpalMethods = require("../../temp/penpal-methods").default; const WopiAPI = require("./wopiapi-iframe"); @@ -271,14 +277,13 @@ window.lastTimeGetProfile = 0; let iframeParent = null; const handleIdentityLogin = (identity, uuid, token) => { - const { loadedIdentities, viamApi } = window; + const { viamApi } = window; const { publicKey } = identity.authentication; - viamApi.setSessionData(uuid, token); localStorage.setItem("uuid", uuid); localStorage.setItem("token", token); localStorage.setItem("authenticatedIdentity", publicKey); - window.currentlyAuthenticatedIdentity = loadedIdentities[publicKey]; + window.currentlyAuthenticatedIdentity = identity; window.lastTimeGetProfile = 0; setKeyForUUID(uuid, publicKey); }; @@ -348,6 +353,7 @@ async function executeRestfulFunction(type, that, fn, config, ...args) { null, "previousaddeddevice" ); + if (loginResponse.data.code !== "200") { return loginResponse.data; } @@ -434,7 +440,7 @@ function getCertificateForPassport(passportUUID, internal) { const passportIdentity = window.currentlyAuthenticatedIdentity; const passport = passportIdentity.getPassport(passportUUID); if (passport === undefined || passport === null) { - createPassportCertificate(passportUUID).then(function(keys) { + createPassportCertificate(passportUUID).then(function (keys) { const cryptoData = new CryptoData(); cryptoData.setPublicKey(keys["publicKeyPEM"]); cryptoData.setPrivateKey(keys["privateKeyPEM"]); @@ -552,7 +558,7 @@ const connection = Penpal.connectToParent({ ...penpalMethods, createIdentity(pinCode) { return new Penpal.Promise(result => { - createPassportCertificate(makeid()).then(function(keys) { + createPassportCertificate(makeid()).then(function (keys) { const newIdentity = new Identity(); const cryptoData = new CryptoData(); cryptoData.setPublicKey(keys["publicKeyPEM"]); @@ -562,6 +568,10 @@ const connection = Penpal.connectToParent({ newIdentity.setPinCode(pinCode); window.currentlyLoadedIdentity = newIdentity; + localStorage.setItem( + "currentlyLoadedIdentity", + JSON.stringify(newIdentity) + ); const { publicKey, x509Certificate } = newIdentity.authentication; window.loadedIdentities[publicKey] = newIdentity; @@ -724,10 +734,7 @@ const connection = Penpal.connectToParent({ }); }); }, - finalizeEmployeeRegistration: async ( - identity, - identifier - ) => { + finalizeEmployeeRegistration: async (identity, identifier) => { viamApi.setIdentity(identity.authentication.publicKey); return executeRestfulFunction( "public", @@ -812,16 +819,9 @@ const connection = Penpal.connectToParent({ const responseToClient = Object.assign({}, identityLoginResponse); if (code === "200") { - if ( - mode === LOGIN_MODES.SMS || - mode === LOGIN_MODES.PREVIOUSLY_ADDED_DEVICE - ) { + if (mode === LOGIN_MODES.PREVIOUSLY_ADDED_DEVICE) { handleIdentityLogin(loginIdentity, data.Uuid, data.Session); await getProfileData(loginIdentity); - - if (mode === LOGIN_MODES.SMS) { - await setIdentityInLocalStorage(loginIdentity); - } } else if (mode === LOGIN_MODES.NEW_DEVICE) { const dataUrl = await generateQrCode( `${data.ActionID},${data.QrCode}` @@ -961,7 +961,7 @@ const connection = Penpal.connectToParent({ }; } }, - identityRestoreAccess(restoreAccessIdentity, identificator) { + identityRestoreAccess(restoreAccessIdentity, identificator, restoreType) { return new Penpal.Promise(result => { viamApi.setSessionData("", ""); viamApi.setIdentity(restoreAccessIdentity.authentication.publicKey); @@ -971,12 +971,163 @@ const connection = Penpal.connectToParent({ viamApi, viamApi.identityRestoreAccess, null, - identificator + identificator, + restoreType ).then(executeResult => { result(executeResult); }); }); }, + identityInitiateSocialRecovery: async accessToken => { + const response = await executeRestfulFunction( + "public", + viamApi, + viamApi.identityInitiateSocialRecovery, + null, + accessToken + ); + + return response; + }, + contactsCheckAccountRecoveryStatus: async () => { + const currentlyLoadedIdentity = localStorage.getItem( + "currentlyLoadedIdentity" + ); + const parsedIdentity = JSON.parse(currentlyLoadedIdentity); + window.currentlyLoadedIdentity = parsedIdentity; + const { publicKey } = parsedIdentity.authentication; + window.loadedIdentities[publicKey] = parsedIdentity; + window.viamAnonymousApi.setIdentity(publicKey); + window.viamApi.setSessionData("", ""); + window.viamApi.setIdentity(publicKey); + + const timeout = ms => new Promise(resolve => setTimeout(resolve, ms)); + + async function checkAccountRecoveryStatus() { + const response = await executeRestfulFunction( + "public", + viamApi, + viamApi.contactsCheckAccountRecoveryStatus, + null + ); + + if (response.data === 0) { + await timeout(1000); + await checkAccountRecoveryStatus(); + return; + } + + const deviceHash = await createDeviceHash(publicKey); + window.viamApi.setDeviceHash(deviceHash); + + const identityLoginResponse = await executeRestfulFunction( + "public", + window.viamApi, + window.viamApi.identityLogin, + null, + "previousaddeddevice" + ); + + const { code, data } = identityLoginResponse; + if (code === "200") { + await setIdentityInLocalStorage(parsedIdentity); + handleIdentityLogin(parsedIdentity, data.Uuid, data.Session); + await getProfileData(parsedIdentity); + localStorage.removeItem("currentlyLoadedIdentity"); + } + } + + await checkAccountRecoveryStatus(); + }, + contactsGetTrusteeContactsPublicKeys: async () => { + try { + const response = await executeRestfulFunction( + "private", + window.viamApi, + window.viamApi.contactsGetTrusteeContactsPublicKeys, + null + ); + + if (response.code !== "200") { + return response; + } + + const responseData = response.data; + const trusteesDevices = Object.values(responseData); + + /** Check if there are new trustees without added secret part */ + const hasNewTrustees = trusteesDevices.some(device => { + const deviceData = Object.values(device); + return deviceData.some(data => data.hasShamir === "0"); + }); + + if (!hasNewTrustees) { + return response; + } + + // Generate and split recovery key + const trusteesUuids = Object.keys(responseData); + const trusteesToDevices = Object.entries(responseData); + const sharesNumber = trusteesUuids.length; + const recoveryKey = generateRecoveryKey(); + let recoveryKeyShares = [recoveryKey]; + // Split the secret when sharesNumber is more than 1 because VereignPublicKey is always returned + if (sharesNumber > 1) { + recoveryKeyShares = getRecoveryKeyShares(recoveryKey, sharesNumber); + const sanityCheckResponse = checkRecoveryKeyCombine( + recoveryKey, + recoveryKeyShares + ); + + if (sanityCheckResponse.code !== "200") { + return sanityCheckResponse; + } + } + + // Encrypt each share with every publicKey of each contact device + const shamirPartsList = await Promise.all( + trusteesToDevices.map(async ([contactUuid, device], index) => { + const deviceIdsToPublicKeys = Object.entries(device); + // Encrypt secret shares in parallel + const deviceIdsToEncryptedPartsList = await Promise.all( + deviceIdsToPublicKeys.map(async ([deviceId, { content }]) => { + const encryptedShare = await encryptShare( + recoveryKeyShares[index], + content + ); + + return [deviceId, encryptedShare]; + }) + ); + // Turn deviceIdsToEncryptedPartsList array to object + const deviceIdsToEncryptedParts = Object.fromEntries( + deviceIdsToEncryptedPartsList + ); + + return [contactUuid, deviceIdsToEncryptedParts]; + }) + ); + // Turn shamirPartsList array to object + const shamirParts = Object.fromEntries(shamirPartsList); + + // Save Shamir parts to database + const saveShamirPartsResponse = await executeRestfulFunction( + "private", + window.viamApi, + window.viamApi.contactsSaveShamirParts, + null, + shamirParts + ); + + if (saveShamirPartsResponse.code !== "200") { + return saveShamirPartsResponse; + } + + return response; + } catch (error) { + return encodeResponse("400", "", error.message); + } + }, parseSMIME, getCurrentlyLoggedInUUID() { return new Penpal.Promise(result => { @@ -1074,7 +1225,7 @@ const connection = Penpal.connectToParent({ emailArg, passportPrivateKey, passportCertificate - ).then(function(keys) { + ).then(function (keys) { const publicKeyOneTime = keys["publicKeyPEM"]; const privateKeyOneTime = keys["privateKeyPEM"]; const certificateOneTime = keys["certificatePEM"]; @@ -1417,10 +1568,7 @@ const connection = Penpal.connectToParent({ vCardImageClaimValue = vCardClaimResponse.data; } - if ( - vCardImageClaimValue && - "state" in vCardImageClaimValue - ) { + if (vCardImageClaimValue && "state" in vCardImageClaimValue) { return encodeResponse("200", vCardImageClaimValue.state, "OK"); } @@ -1480,14 +1628,13 @@ message SignatureData { return encodeResponse("400", "", "Identity not authenticated"); } - // Get vCard and QR Code Coordinates let vCardImageData; let vCardImageClaimValue; let qrCodeImageData; - let qrCodeCoordinates = {fromL: -1, fromR: -1, toL: -1, toR: -1}; + let qrCodeCoordinates = { fromL: -1, fromR: -1, toL: -1, toR: -1 }; if (signatureData) { const vCardImageClaimName = "vCardImage"; @@ -1673,7 +1820,7 @@ message SignatureData { let vCardImageClaimValue; let qrCodeImageData; - let qrCodeCoordinates = {fromL: -1, fromR: -1, toL: -1, toR: -1}; + let qrCodeCoordinates = { fromL: -1, fromR: -1, toL: -1, toR: -1 }; const vCardImageClaimName = "vCardImage"; const defaultTagName = "notag"; @@ -1809,7 +1956,6 @@ message SignatureData { passportChain.reverse(); - const signVCardResponse = await executeRestfulFunction( "private", window.viamApi, @@ -1927,7 +2073,7 @@ message SignatureData { return encodeResponse("200", response.data, "Document created"); }, - getVcardWithQrCode: async (passportUUID, QRCodeContent = null) =>{ + getVcardWithQrCode: async (passportUUID, QRCodeContent = null) => { //TODO: IMPLEMENT QR CODE backend method needed const authenticationPublicKey = localStorage.getItem( "authenticatedIdentity" @@ -1995,7 +2141,7 @@ message SignatureData { ); } } - return encodeResponse("200",vCardImageData, 'vCard got'); + return encodeResponse("200", vCardImageData, "vCard got"); }, documentPutDocument: async ( passportUUID, @@ -2413,7 +2559,7 @@ connection.promise.then(parent => { let previousLocalStorageToken; let previousLocalStorageIdentity; - setInterval(async function() { + setInterval(async function () { if (window.currentlyAuthenticatedIdentity) { const { authentication } = window.currentlyAuthenticatedIdentity; const pinCode = getPincode(authentication.publicKey); @@ -2458,6 +2604,8 @@ connection.promise.then(parent => { identityAuthenticatedEvent = false; window.currentlyLoadedIdentity = null; } + + localStorage.removeItem("currentlyLoadedIdentity"); } if (window.currentlyLoadedIdentity) { diff --git a/javascript/src/lib/secrets.js b/javascript/src/lib/secrets.js new file mode 100644 index 0000000000000000000000000000000000000000..9da60b9782fa0494d2827ae9c0b2193994a7d70d --- /dev/null +++ b/javascript/src/lib/secrets.js @@ -0,0 +1,1043 @@ +// @preserve author Alexander Stetsyuk +// @preserve author Glenn Rempe <glenn@rempe.us> +// @license MIT + +/*jslint passfail: false, bitwise: true, nomen: true, plusplus: true, todo: false, maxerr: 1000 */ +/*global define, require, module, exports, window, Uint32Array */ + +// eslint : http://eslint.org/docs/configuring/ +/*eslint-env node, browser, jasmine */ +/*eslint no-underscore-dangle:0 */ + +// UMD (Universal Module Definition) +// Uses Node, AMD or browser globals to create a module. This module creates +// a global even when AMD is used. This is useful if you have some scripts +// that are loaded by an AMD loader, but they still want access to globals. +// See : https://github.com/umdjs/umd +// See : https://github.com/umdjs/umd/blob/master/returnExportsGlobal.js +// +(function(root, factory) { + "use strict"; + + if (typeof define === "function" && define.amd) { + // AMD. Register as an anonymous module. + define([], function() { + /*eslint-disable no-return-assign */ + return (root.secrets = factory()); + /*eslint-enable no-return-assign */ + }); + } else if (typeof exports === "object") { + // Node. Does not work with strict CommonJS, but + // only CommonJS-like environments that support module.exports, + // like Node. + module.exports = factory(require("crypto")); + } else { + // Browser globals (root is window) + root.secrets = factory(root.crypto); + } +})(this, function() { + "use strict"; + let crypto; + try { + crypto = require("crypto"); + } catch (err) { + console.warn("crypto support is disabled!"); + } + + if (!crypto) { + crypto = window.crypto; + } + + var defaults, config, preGenPadding, runCSPRNGTest, CSPRNGTypes; + + function reset() { + defaults = { + bits: 8, // default number of bits + radix: 16, // work with HEX by default + minBits: 3, + maxBits: 20, // this permits 1,048,575 shares, though going this high is NOT recommended in JS! + bytesPerChar: 2, + maxBytesPerChar: 6, // Math.pow(256,7) > Math.pow(2,53) + + // Primitive polynomials (in decimal form) for Galois Fields GF(2^n), for 2 <= n <= 30 + // The index of each term in the array corresponds to the n for that polynomial + // i.e. to get the polynomial for n=16, use primitivePolynomials[16] + primitivePolynomials: [ + null, + null, + 1, + 3, + 3, + 5, + 3, + 3, + 29, + 17, + 9, + 5, + 83, + 27, + 43, + 3, + 45, + 9, + 39, + 39, + 9, + 5, + 3, + 33, + 27, + 9, + 71, + 39, + 9, + 5, + 83 + ] + }; + config = {}; + preGenPadding = new Array(1024).join("0"); // Pre-generate a string of 1024 0's for use by padLeft(). + runCSPRNGTest = true; + + // WARNING : Never use 'testRandom' except for testing. + CSPRNGTypes = [ + "nodeCryptoRandomBytes", + "browserCryptoGetRandomValues", + "testRandom" + ]; + } + + function isSetRNG() { + if (config && config.rng && typeof config.rng === "function") { + return true; + } + + return false; + } + + // Pads a string `str` with zeros on the left so that its length is a multiple of `bits` + function padLeft(str, multipleOfBits) { + var missing; + + if (multipleOfBits === 0 || multipleOfBits === 1) { + return str; + } + + if (multipleOfBits && multipleOfBits > 1024) { + throw new Error("Padding must be multiples of no larger than 1024 bits."); + } + + multipleOfBits = multipleOfBits || config.bits; + + if (str) { + missing = str.length % multipleOfBits; + } + + if (missing) { + return (preGenPadding + str).slice( + -(multipleOfBits - missing + str.length) + ); + } + + return str; + } + + function hex2bin(str) { + var bin = "", + num, + i; + + for (i = str.length - 1; i >= 0; i--) { + num = parseInt(str[i], 16); + + if (isNaN(num)) { + throw new Error("Invalid hex character."); + } + + bin = padLeft(num.toString(2), 4) + bin; + } + return bin; + } + + function bin2hex(str) { + var hex = "", + num, + i; + + str = padLeft(str, 4); + + for (i = str.length; i >= 4; i -= 4) { + num = parseInt(str.slice(i - 4, i), 2); + if (isNaN(num)) { + throw new Error("Invalid binary character."); + } + hex = num.toString(16) + hex; + } + + return hex; + } + + // Browser supports crypto.getRandomValues() + function hasCryptoGetRandomValues() { + if ( + crypto && + typeof crypto === "object" && + (typeof crypto.getRandomValues === "function" || + typeof crypto.getRandomValues === "object") && + (typeof Uint32Array === "function" || typeof Uint32Array === "object") + ) { + return true; + } + + return false; + } + + // Node.js support for crypto.randomBytes() + function hasCryptoRandomBytes() { + if ( + typeof crypto === "object" && + typeof crypto.randomBytes === "function" + ) { + return true; + } + + return false; + } + + // Returns a pseudo-random number generator of the form function(bits){} + // which should output a random string of 1's and 0's of length `bits`. + // `type` (Optional) : A string representing the CSPRNG that you want to + // force to be loaded, overriding feature detection. Can be one of: + // "nodeCryptoRandomBytes" + // "browserCryptoGetRandomValues" + // + function getRNG(type) { + function construct(bits, arr, radix, size) { + var i = 0, + len, + str = "", + parsedInt; + + if (arr) { + len = arr.length - 1; + } + + while (i < len || str.length < bits) { + // convert any negative nums to positive with Math.abs() + parsedInt = Math.abs(parseInt(arr[i], radix)); + str = str + padLeft(parsedInt.toString(2), size); + i++; + } + + str = str.substr(-bits); + + // return null so this result can be re-processed if the result is all 0's. + if ((str.match(/0/g) || []).length === str.length) { + return null; + } + + return str; + } + + // Node.js : crypto.randomBytes() + // Note : Node.js and crypto.randomBytes() uses the OpenSSL RAND_bytes() function for its CSPRNG. + // Node.js will need to have been compiled with OpenSSL for this to work. + // See : https://github.com/joyent/node/blob/d8baf8a2a4481940bfed0196308ae6189ca18eee/src/node_crypto.cc#L4696 + // See : https://www.openssl.org/docs/crypto/rand.html + function nodeCryptoRandomBytes(bits) { + var buf, + bytes, + radix, + size, + str = null; + + radix = 16; + size = 4; + bytes = Math.ceil(bits / 8); + + while (str === null) { + buf = crypto.randomBytes(bytes); + str = construct(bits, buf.toString("hex"), radix, size); + } + + return str; + } + + // Browser : crypto.getRandomValues() + // See : https://dvcs.w3.org/hg/webcrypto-api/raw-file/tip/spec/Overview.html#dfn-Crypto + // See : https://developer.mozilla.org/en-US/docs/Web/API/RandomSource/getRandomValues + // Supported Browsers : http://caniuse.com/#search=crypto.getRandomValues + function browserCryptoGetRandomValues(bits) { + var elems, + radix, + size, + str = null; + + radix = 10; + size = 32; + elems = Math.ceil(bits / 32); + while (str === null) { + str = construct( + bits, + crypto.getRandomValues(new Uint32Array(elems)), + radix, + size + ); + } + + return str; + } + + // ///////////////////////////////////////////////////////////// + // WARNING : DO NOT USE. For testing purposes only. + // ///////////////////////////////////////////////////////////// + // This function will return repeatable non-random test bits. Can be used + // for testing only. Node.js does not return proper random bytes + // when run within a PhantomJS container. + function testRandom(bits) { + var arr, + elems, + int, + radix, + size, + str = null; + + radix = 10; + size = 32; + elems = Math.ceil(bits / 32); + int = 123456789; + arr = new Uint32Array(elems); + + // Fill every element of the Uint32Array with the same int. + for (var i = 0; i < arr.length; i++) { + arr[i] = int; + } + + while (str === null) { + str = construct(bits, arr, radix, size); + } + + return str; + } + + // Return a random generator function for browsers that support + // crypto.getRandomValues() or Node.js compiled with OpenSSL support. + // WARNING : NEVER use testRandom outside of a testing context. Totally non-random! + if (type && type === "testRandom") { + config.typeCSPRNG = type; + return testRandom; + } else if (type && type === "nodeCryptoRandomBytes") { + config.typeCSPRNG = type; + return nodeCryptoRandomBytes; + } else if (type && type === "browserCryptoGetRandomValues") { + config.typeCSPRNG = type; + return browserCryptoGetRandomValues; + } else if (hasCryptoRandomBytes()) { + config.typeCSPRNG = "nodeCryptoRandomBytes"; + return nodeCryptoRandomBytes; + } else if (hasCryptoGetRandomValues()) { + config.typeCSPRNG = "browserCryptoGetRandomValues"; + return browserCryptoGetRandomValues; + } + } + + // Splits a number string `bits`-length segments, after first + // optionally zero-padding it to a length that is a multiple of `padLength. + // Returns array of integers (each less than 2^bits-1), with each element + // representing a `bits`-length segment of the input string from right to left, + // i.e. parts[0] represents the right-most `bits`-length segment of the input string. + function splitNumStringToIntArray(str, padLength) { + var parts = [], + i; + + if (padLength) { + str = padLeft(str, padLength); + } + + for (i = str.length; i > config.bits; i -= config.bits) { + parts.push(parseInt(str.slice(i - config.bits, i), 2)); + } + + parts.push(parseInt(str.slice(0, i), 2)); + + return parts; + } + + // Polynomial evaluation at `x` using Horner's Method + // NOTE: fx=fx * x + coeff[i] -> exp(log(fx) + log(x)) + coeff[i], + // so if fx===0, just set fx to coeff[i] because + // using the exp/log form will result in incorrect value + function horner(x, coeffs) { + var logx = config.logs[x], + fx = 0, + i; + + for (i = coeffs.length - 1; i >= 0; i--) { + if (fx !== 0) { + fx = + config.exps[(logx + config.logs[fx]) % config.maxShares] ^ coeffs[i]; + } else { + fx = coeffs[i]; + } + } + + return fx; + } + + // Evaluate the Lagrange interpolation polynomial at x = `at` + // using x and y Arrays that are of the same length, with + // corresponding elements constituting points on the polynomial. + function lagrange(at, x, y) { + var sum = 0, + len, + product, + i, + j; + + for (i = 0, len = x.length; i < len; i++) { + if (y[i]) { + product = config.logs[y[i]]; + + for (j = 0; j < len; j++) { + if (i !== j) { + if (at === x[j]) { + // happens when computing a share that is in the list of shares used to compute it + product = -1; // fix for a zero product term, after which the sum should be sum^0 = sum, not sum^1 + break; + } + product = + (product + + config.logs[at ^ x[j]] - + config.logs[x[i] ^ x[j]] + + config.maxShares) % + config.maxShares; // to make sure it's not negative + } + } + + // though exps[-1] === undefined and undefined ^ anything = anything in + // chrome, this behavior may not hold everywhere, so do the check + sum = product === -1 ? sum : sum ^ config.exps[product]; + } + } + + return sum; + } + + // This is the basic polynomial generation and evaluation function + // for a `config.bits`-length secret (NOT an arbitrary length) + // Note: no error-checking at this stage! If `secret` is NOT + // a NUMBER less than 2^bits-1, the output will be incorrect! + function getShares(secret, numShares, threshold) { + var shares = [], + coeffs = [secret], + i, + len; + + for (i = 1; i < threshold; i++) { + coeffs[i] = parseInt(config.rng(config.bits), 2); + } + + for (i = 1, len = numShares + 1; i < len; i++) { + shares[i - 1] = { + x: i, + y: horner(i, coeffs) + }; + } + + return shares; + } + + function constructPublicShareString(bits, id, data) { + var bitsBase36, idHex, idMax, idPaddingLen, newShareString; + + id = parseInt(id, config.radix); + bits = parseInt(bits, 10) || config.bits; + bitsBase36 = bits.toString(36).toUpperCase(); + idMax = Math.pow(2, bits) - 1; + idPaddingLen = idMax.toString(config.radix).length; + idHex = padLeft(id.toString(config.radix), idPaddingLen); + + if (typeof id !== "number" || id % 1 !== 0 || id < 1 || id > idMax) { + throw new Error( + "Share id must be an integer between 1 and " + idMax + ", inclusive." + ); + } + + newShareString = bitsBase36 + idHex + data; + + return newShareString; + } + + // EXPORTED FUNCTIONS + // ////////////////// + + var secrets = { + init: function(bits, rngType) { + var logs = [], + exps = [], + x = 1, + primitive, + i; + + // reset all config back to initial state + reset(); + + if ( + bits && + (typeof bits !== "number" || + bits % 1 !== 0 || + bits < defaults.minBits || + bits > defaults.maxBits) + ) { + throw new Error( + "Number of bits must be an integer between " + + defaults.minBits + + " and " + + defaults.maxBits + + ", inclusive." + ); + } + + if (rngType && CSPRNGTypes.indexOf(rngType) === -1) { + throw new Error("Invalid RNG type argument : '" + rngType + "'"); + } + + config.radix = defaults.radix; + config.bits = bits || defaults.bits; + config.size = Math.pow(2, config.bits); + config.maxShares = config.size - 1; + + // Construct the exp and log tables for multiplication. + primitive = defaults.primitivePolynomials[config.bits]; + + for (i = 0; i < config.size; i++) { + exps[i] = x; + logs[x] = i; + x = x << 1; // Left shift assignment + if (x >= config.size) { + x = x ^ primitive; // Bitwise XOR assignment + x = x & config.maxShares; // Bitwise AND assignment + } + } + + config.logs = logs; + config.exps = exps; + + if (rngType) { + this.setRNG(rngType); + } + + if (!isSetRNG()) { + this.setRNG(); + } + + if ( + !isSetRNG() || + !config.bits || + !config.size || + !config.maxShares || + !config.logs || + !config.exps || + config.logs.length !== config.size || + config.exps.length !== config.size + ) { + throw new Error("Initialization failed."); + } + }, + + // Evaluates the Lagrange interpolation polynomial at x=`at` for + // individual config.bits-length segments of each share in the `shares` + // Array. Each share is expressed in base `inputRadix`. The output + // is expressed in base `outputRadix'. + combine: function(shares, at) { + var i, + j, + len, + len2, + result = "", + setBits, + share, + splitShare, + x = [], + y = []; + + at = at || 0; + + for (i = 0, len = shares.length; i < len; i++) { + share = this.extractShareComponents(shares[i]); + + // All shares must have the same bits settings. + if (setBits === undefined) { + setBits = share.bits; + } else if (share.bits !== setBits) { + throw new Error("Mismatched shares: Different bit settings."); + } + + // Reset everything to the bit settings of the shares. + if (config.bits !== setBits) { + this.init(setBits); + } + + // Proceed if this share.id is not already in the Array 'x' and + // then split each share's hex data into an Array of Integers, + // then 'rotate' those arrays where the first element of each row is converted to + // its own array, the second element of each to its own Array, and so on for all of the rest. + // Essentially zipping all of the shares together. + // + // e.g. + // [ 193, 186, 29, 150, 5, 120, 44, 46, 49, 59, 6, 1, 102, 98, 177, 196 ] + // [ 53, 105, 139, 49, 187, 240, 91, 92, 98, 118, 12, 2, 204, 196, 127, 149 ] + // [ 146, 211, 249, 167, 209, 136, 118, 114, 83, 77, 10, 3, 170, 166, 206, 81 ] + // + // becomes: + // + // [ [ 193, 53, 146 ], + // [ 186, 105, 211 ], + // [ 29, 139, 249 ], + // [ 150, 49, 167 ], + // [ 5, 187, 209 ], + // [ 120, 240, 136 ], + // [ 44, 91, 118 ], + // [ 46, 92, 114 ], + // [ 49, 98, 83 ], + // [ 59, 118, 77 ], + // [ 6, 12, 10 ], + // [ 1, 2, 3 ], + // [ 102, 204, 170 ], + // [ 98, 196, 166 ], + // [ 177, 127, 206 ], + // [ 196, 149, 81 ] ] + // + if (x.indexOf(share.id) === -1) { + x.push(share.id); + splitShare = splitNumStringToIntArray(hex2bin(share.data)); + for (j = 0, len2 = splitShare.length; j < len2; j++) { + y[j] = y[j] || []; + y[j][x.length - 1] = splitShare[j]; + } + } + } + + // Extract the secret from the 'rotated' share data and return a + // string of Binary digits which represent the secret directly. or in the + // case of a newShare() return the binary string representing just that + // new share. + for (i = 0, len = y.length; i < len; i++) { + result = padLeft(lagrange(at, x, y[i]).toString(2)) + result; + } + + // If 'at' is non-zero combine() was called from newShare(). In this + // case return the result (the new share data) directly. + // + // Otherwise find the first '1' which was added in the share() function as a padding marker + // and return only the data after the padding and the marker. Convert this Binary string + // to hex, which represents the final secret result (which can be converted from hex back + // to the original string in user space using `hex2str()`). + return bin2hex(at >= 1 ? result : result.slice(result.indexOf("1") + 1)); + }, + + getConfig: function() { + var obj = {}; + obj.radix = config.radix; + obj.bits = config.bits; + obj.maxShares = config.maxShares; + obj.hasCSPRNG = isSetRNG(); + obj.typeCSPRNG = config.typeCSPRNG; + return obj; + }, + + // Given a public share, extract the bits (Integer), share ID (Integer), and share data (Hex) + // and return an Object containing those components. + extractShareComponents: function(share) { + var bits, + id, + idLen, + max, + obj = {}, + regexStr, + shareComponents; + + // Extract the first char which represents the bits in Base 36 + bits = parseInt(share.substr(0, 1), 36); + + if ( + bits && + (typeof bits !== "number" || + bits % 1 !== 0 || + bits < defaults.minBits || + bits > defaults.maxBits) + ) { + throw new Error( + "Invalid share : Number of bits must be an integer between " + + defaults.minBits + + " and " + + defaults.maxBits + + ", inclusive." + ); + } + + // calc the max shares allowed for given bits + max = Math.pow(2, bits) - 1; + + // Determine the ID length which is variable and based on the bit count. + idLen = (Math.pow(2, bits) - 1).toString(config.radix).length; + + // Extract all the parts now that the segment sizes are known. + regexStr = "^([a-kA-K3-9]{1})([a-fA-F0-9]{" + idLen + "})([a-fA-F0-9]+)$"; + shareComponents = new RegExp(regexStr).exec(share); + + // The ID is a Hex number and needs to be converted to an Integer + if (shareComponents) { + id = parseInt(shareComponents[2], config.radix); + } + + if (typeof id !== "number" || id % 1 !== 0 || id < 1 || id > max) { + throw new Error( + "Invalid share : Share id must be an integer between 1 and " + + config.maxShares + + ", inclusive." + ); + } + + if (shareComponents && shareComponents[3]) { + obj.bits = bits; + obj.id = id; + obj.data = shareComponents[3]; + return obj; + } + + throw new Error("The share data provided is invalid : " + share); + }, + + // Set the PRNG to use. If no RNG function is supplied, pick a default using getRNG() + setRNG: function(rng) { + var errPrefix = "Random number generator is invalid ", + errSuffix = + " Supply an CSPRNG of the form function(bits){} that returns a string containing 'bits' number of random 1's and 0's."; + + if (rng && typeof rng === "string" && CSPRNGTypes.indexOf(rng) === -1) { + throw new Error("Invalid RNG type argument : '" + rng + "'"); + } + + // If RNG was not specified at all, + // try to pick one appropriate for this env. + if (!rng) { + rng = getRNG(); + } + + // If `rng` is a string, try to forcibly + // set the RNG to the type specified. + if (rng && typeof rng === "string") { + rng = getRNG(rng); + } + + if (runCSPRNGTest) { + if (rng && typeof rng !== "function") { + throw new Error(errPrefix + "(Not a function)." + errSuffix); + } + + if (rng && typeof rng(config.bits) !== "string") { + throw new Error(errPrefix + "(Output is not a string)." + errSuffix); + } + + if (rng && !parseInt(rng(config.bits), 2)) { + throw new Error( + errPrefix + + "(Binary string output not parseable to an Integer)." + + errSuffix + ); + } + + if (rng && rng(config.bits).length > config.bits) { + throw new Error( + errPrefix + + "(Output length is greater than config.bits)." + + errSuffix + ); + } + + if (rng && rng(config.bits).length < config.bits) { + throw new Error( + errPrefix + "(Output length is less than config.bits)." + errSuffix + ); + } + } + + config.rng = rng; + + return true; + }, + + // Converts a given UTF16 character string to the HEX representation. + // Each character of the input string is represented by + // `bytesPerChar` bytes in the output string which defaults to 2. + str2hex: function(str, bytesPerChar) { + var hexChars, + max, + out = "", + neededBytes, + num, + i, + len; + + if (typeof str !== "string") { + throw new Error("Input must be a character string."); + } + + if (!bytesPerChar) { + bytesPerChar = defaults.bytesPerChar; + } + + if ( + typeof bytesPerChar !== "number" || + bytesPerChar < 1 || + bytesPerChar > defaults.maxBytesPerChar || + bytesPerChar % 1 !== 0 + ) { + throw new Error( + "Bytes per character must be an integer between 1 and " + + defaults.maxBytesPerChar + + ", inclusive." + ); + } + + hexChars = 2 * bytesPerChar; + max = Math.pow(16, hexChars) - 1; + + for (i = 0, len = str.length; i < len; i++) { + num = str[i].charCodeAt(); + + if (isNaN(num)) { + throw new Error("Invalid character: " + str[i]); + } + + if (num > max) { + neededBytes = Math.ceil(Math.log(num + 1) / Math.log(256)); + throw new Error( + "Invalid character code (" + + num + + "). Maximum allowable is 256^bytes-1 (" + + max + + "). To convert this character, use at least " + + neededBytes + + " bytes." + ); + } + + out = padLeft(num.toString(16), hexChars) + out; + } + return out; + }, + + // Converts a given HEX number string to a UTF16 character string. + hex2str: function(str, bytesPerChar) { + var hexChars, + out = "", + i, + len; + + if (typeof str !== "string") { + throw new Error("Input must be a hexadecimal string."); + } + bytesPerChar = bytesPerChar || defaults.bytesPerChar; + + if ( + typeof bytesPerChar !== "number" || + bytesPerChar % 1 !== 0 || + bytesPerChar < 1 || + bytesPerChar > defaults.maxBytesPerChar + ) { + throw new Error( + "Bytes per character must be an integer between 1 and " + + defaults.maxBytesPerChar + + ", inclusive." + ); + } + + hexChars = 2 * bytesPerChar; + + str = padLeft(str, hexChars); + + for (i = 0, len = str.length; i < len; i += hexChars) { + out = + String.fromCharCode(parseInt(str.slice(i, i + hexChars), 16)) + out; + } + + return out; + }, + + // Generates a random bits-length number string using the PRNG + random: function(bits) { + if ( + typeof bits !== "number" || + bits % 1 !== 0 || + bits < 2 || + bits > 65536 + ) { + throw new Error( + "Number of bits must be an Integer between 1 and 65536." + ); + } + + return bin2hex(config.rng(bits)); + }, + + // Divides a `secret` number String str expressed in radix `inputRadix` (optional, default 16) + // into `numShares` shares, each expressed in radix `outputRadix` (optional, default to `inputRadix`), + // requiring `threshold` number of shares to reconstruct the secret. + // Optionally, zero-pads the secret to a length that is a multiple of padLength before sharing. + share: function(secret, numShares, threshold, padLength) { + var neededBits, + subShares, + x = new Array(numShares), + y = new Array(numShares), + i, + j, + len; + + // Security: + // For additional security, pad in multiples of 128 bits by default. + // A small trade-off in larger share size to help prevent leakage of information + // about small-ish secrets and increase the difficulty of attacking them. + padLength = padLength || 128; + + if (typeof secret !== "string") { + throw new Error("Secret must be a string."); + } + + if ( + typeof numShares !== "number" || + numShares % 1 !== 0 || + numShares < 2 + ) { + throw new Error( + "Number of shares must be an integer between 2 and 2^bits-1 (" + + config.maxShares + + "), inclusive." + ); + } + + if (numShares > config.maxShares) { + neededBits = Math.ceil(Math.log(numShares + 1) / Math.LN2); + throw new Error( + "Number of shares must be an integer between 2 and 2^bits-1 (" + + config.maxShares + + "), inclusive. To create " + + numShares + + " shares, use at least " + + neededBits + + " bits." + ); + } + + if ( + typeof threshold !== "number" || + threshold % 1 !== 0 || + threshold < 2 + ) { + throw new Error( + "Threshold number of shares must be an integer between 2 and 2^bits-1 (" + + config.maxShares + + "), inclusive." + ); + } + + if (threshold > config.maxShares) { + neededBits = Math.ceil(Math.log(threshold + 1) / Math.LN2); + throw new Error( + "Threshold number of shares must be an integer between 2 and 2^bits-1 (" + + config.maxShares + + "), inclusive. To use a threshold of " + + threshold + + ", use at least " + + neededBits + + " bits." + ); + } + + if (threshold > numShares) { + throw new Error( + "Threshold number of shares was " + + threshold + + " but must be less than or equal to the " + + numShares + + " shares specified as the total to generate." + ); + } + + if ( + typeof padLength !== "number" || + padLength % 1 !== 0 || + padLength < 0 || + padLength > 1024 + ) { + throw new Error( + "Zero-pad length must be an integer between 0 and 1024 inclusive." + ); + } + + secret = "1" + hex2bin(secret); // prepend a 1 as a marker so that we can preserve the correct number of leading zeros in our secret + secret = splitNumStringToIntArray(secret, padLength); + + for (i = 0, len = secret.length; i < len; i++) { + subShares = getShares(secret[i], numShares, threshold); + for (j = 0; j < numShares; j++) { + x[j] = x[j] || subShares[j].x.toString(config.radix); + y[j] = padLeft(subShares[j].y.toString(2)) + (y[j] || ""); + } + } + + for (i = 0; i < numShares; i++) { + x[i] = constructPublicShareString(config.bits, x[i], bin2hex(y[i])); + } + + return x; + }, + + // Generate a new share with id `id` (a number between 1 and 2^bits-1) + // `id` can be a Number or a String in the default radix (16) + newShare: function(id, shares) { + var share, radid; + + if (id && typeof id === "string") { + id = parseInt(id, config.radix); + } + + radid = id.toString(config.radix); + + if (id && radid && shares && shares[0]) { + share = this.extractShareComponents(shares[0]); + return constructPublicShareString( + share.bits, + radid, + this.combine(shares, id) + ); + } + + throw new Error("Invalid 'id' or 'shares' Array argument to newShare()."); + }, + + /* test-code */ + // export private functions so they can be unit tested directly. + _reset: reset, + _padLeft: padLeft, + _hex2bin: hex2bin, + _bin2hex: bin2hex, + _hasCryptoGetRandomValues: hasCryptoGetRandomValues, + _hasCryptoRandomBytes: hasCryptoRandomBytes, + _getRNG: getRNG, + _isSetRNG: isSetRNG, + _splitNumStringToIntArray: splitNumStringToIntArray, + _horner: horner, + _lagrange: lagrange, + _getShares: getShares, + _constructPublicShareString: constructPublicShareString + /* end-test-code */ + }; + + // Always initialize secrets with default settings. + secrets.init(); + + return secrets; +}); diff --git a/javascript/src/utilities/numberUtilities.js b/javascript/src/utilities/numberUtilities.js new file mode 100644 index 0000000000000000000000000000000000000000..dbee13839522df1f54fe79ff2fa57befd736ae68 --- /dev/null +++ b/javascript/src/utilities/numberUtilities.js @@ -0,0 +1,14 @@ +export function getRandomInt(max) { + return Math.floor(Math.random() * Math.floor(max)); +} + +export function getSliceRange(max) { + const beginIndex = getRandomInt(max); + const endIndex = getRandomInt(max); + + if (beginIndex === endIndex) { + return getSliceRange(max); + } + + return { beginIndex, endIndex }; +} diff --git a/javascript/src/utilities/secrets.js b/javascript/src/utilities/secrets.js new file mode 100644 index 0000000000000000000000000000000000000000..2223ae6533e57d2573b3a473e94f0c7e8287adfd --- /dev/null +++ b/javascript/src/utilities/secrets.js @@ -0,0 +1,98 @@ +import secrets from "../lib/secrets"; +import { encryptMessage } from "./signingUtilities"; +import { encodeResponse } from "./appUtility"; +import { getSliceRange } from "./numberUtilities"; +import { THRESHOLD } from "../constants/secrets"; + +/** Initialize + */ +export const initSecrets = (bits, rngType) => secrets.init(bits, rngType); + +export const setRNG = rngType => secrets.setRNG(rngType); +export const getSecretsConfig = () => secrets.getConfig(); + +/** + * Function generates a random bits length string, and output it in hexadecimal format + * + * @param {number} bits + * @returns {string} hex + */ +export const generateSecret = bits => secrets.random(bits); + +/** + * Divide a secret expressed in hexadecimal form into numShares number of shares, requiring that threshold number of shares be present for reconstructing the secret + * + * @param {string} secret + * @param {number} numShares + * @param {number} threshold + * @param {number} [padLength=128] + * @returns {array} + */ +export const divideSecretToShares = ( + secret, + numShares, + threshold, + padLength = 128 +) => secrets.share(secret, numShares, threshold, padLength); + +/** + * Reconstructs a secret from shares + * + * @param {array} shares + * @returns {string} + */ +export const combineSecret = shares => secrets.combine(shares); + +export const encryptShare = async (share, publicKey) => + await encryptMessage(share, publicKey, "secretPart"); + +/** Account Recovery key management */ + +export const generateRecoveryKey = () => { + const recoveryKey = generateSecret(512); + return recoveryKey; +}; + +export const getRecoveryKeyShares = (recoveryKey, sharesNumber) => { + return divideSecretToShares(recoveryKey, sharesNumber, THRESHOLD); +}; + +function getSecretSliceRange(max) { + const { beginIndex, endIndex } = getSliceRange(max); + if (endIndex - beginIndex < THRESHOLD) { + return getSecretSliceRange(max); + } + + return { beginIndex, endIndex }; +} + +export const checkRecoveryKeyCombine = (recoveryKey, recoveryKeyShares) => { + let checkKey; + + const { beginIndex, endIndex } = getSecretSliceRange( + recoveryKeyShares.length + 1 + ); + + checkKey = combineSecret(recoveryKeyShares.slice(beginIndex, endIndex)); + if (checkKey !== recoveryKey) { + return encodeResponse( + "400", + "", + "Sanity check with required number of shares failed" + ); + } + checkKey = combineSecret(recoveryKeyShares.slice(0, 1)); + if (checkKey === recoveryKey) { + return encodeResponse( + "400", + "", + "Sanity check with less than required shares failed" + ); + } + checkKey = combineSecret(recoveryKeyShares); + if (checkKey !== recoveryKey) { + return encodeResponse("400", "", "Sanity check with all shares failed"); + } + + return encodeResponse("200", "", "Check passed"); +};