diff --git a/.gitignore b/.gitignore index 721988d5..96f07ab1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ node_modules # DAppNodeSDK release directories build_* /docker.tar.gz + +.env +web_services/foxx/*.zip \ No newline at end of file diff --git a/web_services/foxx/.env.sample b/web_services/foxx/.env.sample new file mode 100644 index 00000000..0b2accb3 --- /dev/null +++ b/web_services/foxx/.env.sample @@ -0,0 +1,4 @@ +ARANGO_SERVER=http://localhost:8529 +ARANGO_DATABASE= +ARANGO_USERNAME= +ARANGO_PASSWORD= diff --git a/web_services/foxx/apply5.zip b/web_services/foxx/apply5.zip deleted file mode 100644 index 571c499d..00000000 Binary files a/web_services/foxx/apply5.zip and /dev/null differ diff --git a/web_services/foxx/apply5/apply.js b/web_services/foxx/apply5/apply.js new file mode 100755 index 00000000..281a8508 --- /dev/null +++ b/web_services/foxx/apply5/apply.js @@ -0,0 +1,108 @@ +"use strict"; +const createRouter = require("@arangodb/foxx/router"); +const joi = require("joi"); +const { db: arango, ArangoError } = require("@arangodb"); +const nacl = require("tweetnacl"); +const db = require("./db"); +const operations = require("./operations"); +const schemas = require("./schemas"); +const errors = require("./errors"); + +const router = createRouter(); +module.context.use(router); +const operationsHashesColl = arango._collection("operationsHashes"); + +const handlers = { + operationsPut: function (req, res) { + const op = req.body; + const hash = req.param("hash"); + op.hash = hash; + try { + // decrypt first to use the original hash for querying operationsHashesColl + if (op.name == "Link ContextId") { + operations.decrypt(op); + } + if (operationsHashesColl.exists(op.hash)) { + throw new errors.OperationAppliedBeforeError(op.hash); + } + operations.verify(op); + op.result = operations.apply(op); + op.state = "applied"; + operationsHashesColl.insert({ _key: op.hash }); + if (op.name == "Link ContextId") { + operations.encrypt(op); + } + } catch (e) { + op.state = "failed"; + if (e instanceof ArangoError) { + e.arangoErrorNum = e.errorNum; + e.errorNum = errors.ARANGO_ERROR; + } + op.result = { + message: e.message || e, + stack: !(e instanceof errors.BrightIDError) ? e.stack : undefined, + errorNum: e.errorNum, + arangoErrorNum: e.arangoErrorNum, + }; + } + db.upsertOperation(op); + res.send({ success: true, state: op.state, result: op.result }); + }, +}; + +// add blockTime to operation schema +schemas.schemas.operation = joi + .alternatives() + .try( + Object.values(schemas.operations).map((op) => { + op.blockTime = joi + .number() + .required() + .description("milliseconds since epoch when the block was created"); + return joi.object(op); + }) + ) + .description( + "Send operations to idchain to be applied to BrightID nodes' databases after consensus" + ); + +router + .put("/operations/:hash", handlers.operationsPut) + .pathParam( + "hash", + joi.string().required().description("sha256 hash of the operation message") + ) + .body(schemas.schemas.operation) + .summary("Apply operation after consensus") + .description("Apply operation after consensus.") + .response(null); + +module.context.use(function (req, res, next) { + try { + next(); + } catch (e) { + if (e.cause && e.cause.isJoi) { + e.code = 400; + if ( + req._raw.url.includes("operations") && + e.cause.details && + e.cause.details.length > 0 + ) { + let msg1 = ""; + const msg2 = "invalid operation name"; + e.cause.details.forEach((d) => { + if (!d.message.includes('"name" must be one of')) { + msg1 += `${d.message}, `; + } + }); + e.message = msg1 || msg2; + } + } + console.group("Error returned"); + console.log("url:", req._raw.requestType, req._raw.url); + console.log("error:", e); + console.log("body:", req.body); + console.groupEnd(); + res.throw(e.code || 500, e); + } +}); diff --git a/web_services/foxx/apply5/db.js b/web_services/foxx/apply5/db.js new file mode 100755 index 00000000..9b5ccd31 --- /dev/null +++ b/web_services/foxx/apply5/db.js @@ -0,0 +1,1244 @@ +"use strict"; +const { sha256 } = require("@arangodb/crypto"); +const { query, db, aql } = require("@arangodb"); +const _ = require("lodash"); +const stringify = require("fast-json-stable-stringify"); +const nacl = require("tweetnacl"); +const { + urlSafeB64ToB64, + priv2addr, + getNaclKeyPair, + getEthKeyPair, + getConsensusSenderAddress, + recoverEthSigner, +} = require("./encoding"); +const parser = require("expr-eval").Parser; +const errors = require("./errors"); + +const connectionsColl = db._collection("connections"); +const connectionsHistoryColl = db._collection("connectionsHistory"); +const groupsColl = db._collection("groups"); +const usersInGroupsColl = db._collection("usersInGroups"); +const usersColl = db._collection("users"); +const contextsColl = db._collection("contexts"); +const appsColl = db._collection("apps"); +const sponsorshipsColl = db._collection("sponsorships"); +const operationsColl = db._collection("operations"); +const invitationsColl = db._collection("invitations"); +const verificationsColl = db._collection("verifications"); +const variablesColl = db._collection("variables"); +const testblocksColl = db._collection("testblocks"); + +function addConnection(key1, key2, timestamp) { + // this function is deprecated and will be removed on v6 + connect({ id1: key1, id2: key2, timestamp }); + connect({ id1: key2, id2: key1, timestamp }); +} + +function connect(op) { + let { + id1: key1, + id2: key2, + level, + reportReason, + replacedWith, + requestProof, + timestamp, + } = op; + + const _from = "users/" + key1; + const _to = "users/" + key2; + if (level == "recovery") { + const tf = connectionsColl.firstExample({ _from: _to, _to: _from }); + if (!tf || !["already known", "recovery"].includes(tf.level)) { + throw new errors.IneligibleRecoveryConnection(); + } + } + + // create user by adding connection if it's not created + // todo: we should prevent non-verified users from creating new users by making connections. + let u1 = loadUser(key1); + let u2 = loadUser(key2); + if (!u1) { + u1 = createUser(key1, timestamp); + } + if (!u2) { + u2 = createUser(key2, timestamp); + } + + // set the first verified user that connect to a user as its parent + let verifications = userVerifications(key1); + if (!u2.parent && verifications.map((v) => v.name).includes("BrightID")) { + usersColl.update(u2, { parent: key1 }); + } + + const conn = connectionsColl.firstExample({ _from, _to }); + + if (level != "reported") { + // clear reportReason for levels other than reported + reportReason = null; + } + if (level != "reported" || reportReason != "replaced") { + // clear replacedWith for levels other than reported + // and reportReason other than replaced + replacedWith = null; + } + if (replacedWith && !loadUser(replacedWith)) { + throw new errors.UserNotFoundError(replacedWith); + } + if (!level) { + // Set 'just met' as confidence level when old addConnection is called + // and there was no other level set directly using Connect + // this if should be removed when v5 dropped and "Add Connection" operation removed + level = conn ? conn.level : "just met"; + } + + connectionsHistoryColl.insert({ + _from, + _to, + level, + reportReason, + replacedWith, + requestProof, + timestamp, + }); + + if (!conn) { + connectionsColl.insert({ + _from, + _to, + level, + reportReason, + replacedWith, + requestProof, + timestamp, + initTimestamp: timestamp, + }); + } else { + connectionsColl.update(conn, { + level, + reportReason, + replacedWith, + requestProof, + timestamp, + }); + } +} + +function removeConnection(reporter, reported, reportReason, timestamp) { + // this function is deprecated and will be removed on v6 + connect({ + id1: reporter, + id2: reported, + level: "reported", + reportReason, + timestamp, + }); +} + +function userConnections(userId, direction = "outbound") { + let query, resIdAttr; + if (direction == "outbound") { + query = { _from: "users/" + userId }; + resIdAttr = "_to"; + } else { + query = { _to: "users/" + userId }; + resIdAttr = "_from"; + } + return connectionsColl + .byExample(query) + .toArray() + .map((conn) => { + return { + id: conn[resIdAttr].replace("users/", ""), + level: conn.level, + reportReason: conn.reportReason || undefined, + timestamp: conn.timestamp, + }; + }); +} + +function userToDic(userId) { + const u = usersColl.document("users/" + userId); + return { + id: u._key, + // all signing keys will be returned on v6 + signingKey: u.signingKeys[0], + // score is deprecated and will be removed on v6 + score: u.score, + verifications: userVerifications(u._key).map((v) => v.name), + hasPrimaryGroup: hasPrimaryGroup(u._key), + // trusted is deprecated and will be replaced by recoveryConnections on v6 + trusted: getRecoveryConnections(u._key), + // flaggers is deprecated and will be replaced by reporters on v6 + flaggers: getReporters(u._key), + createdAt: u.createdAt, + // eligible_groups is deprecated and will be removed on v6 + eligible_groups: u.eligible_groups || [], + }; +} + +function getReporters(user) { + const reporters = {}; + connectionsColl + .byExample({ + _to: "users/" + user, + level: "reported", + }) + .toArray() + .forEach((c) => { + reporters[c._from.replace("users/", "")] = c.reportReason; + }); + return reporters; +} + +function groupMembers(groupId) { + return usersInGroupsColl + .byExample({ + _to: "groups/" + groupId, + }) + .toArray() + .map((e) => e._from.replace("users/", "")); +} + +// this function is deprecated and will be removed on v6 +function updateEligibleGroups(userId, connections, currentGroups) { + connections = connections.map((uId) => "users/" + uId); + currentGroups = currentGroups.map((gId) => "groups/" + gId); + const user = "users/" + userId; + const candidates = query` + FOR edge in ${usersInGroupsColl} + FILTER edge._from in ${connections} + FILTER edge._to NOT IN ${currentGroups} + COLLECT group=edge._to WITH COUNT INTO count + SORT count DESC + RETURN { + group, + count + } + `.toArray(); + const groupIds = candidates.map((x) => x.group); + const groupCounts = query` + FOR ug in ${usersInGroupsColl} + FILTER ug._to in ${groupIds} + COLLECT id=ug._to WITH COUNT INTO count + return { + id, + count + } + `.toArray(); + + const groupCountsDic = {}; + + groupCounts.map(function (row) { + groupCountsDic[row.id] = row.count; + }); + + const eligible_groups = candidates + .filter((g) => g.count * 2 >= groupCountsDic[g.group]) + .map((g) => g.group.replace("groups/", "")); + usersColl.update(userId, { + eligible_groups, + eligible_timestamp: Date.now(), + }); + return eligible_groups; +} + +// this function is deprecated and will be removed on v6 +function updateEligibles(groupId) { + const members = groupMembers(groupId); + const neighbors = []; + const isKnown = (c) => + ["just met", "already known", "recovery"].includes(c.level); + + members.forEach((member) => { + const conns = connectionsColl + .byExample({ + _from: "users/" + member, + }) + .toArray() + .filter(isKnown) + .map((c) => c._to.replace("users/", "")) + .filter((u) => !members.includes(u)); + neighbors.push(...conns); + }); + + const counts = {}; + for (let neighbor of neighbors) { + counts[neighbor] = (counts[neighbor] || 0) + 1; + } + const eligibles = Object.keys(counts).filter((neighbor) => { + return counts[neighbor] >= members.length / 2; + }); + // storing eligible groups on users documents and updating them + // from this route will be removed when clients updated to use + // new GET /groups/{id} result to show eligibles in invite list + eligibles.forEach((neighbor) => { + let { eligible_groups } = usersColl.document(neighbor); + eligible_groups = eligible_groups || []; + if (eligible_groups.indexOf(groupId) == -1) { + eligible_groups.push(groupId); + usersColl.update(neighbor, { + eligible_groups, + }); + } + }); + return eligibles; +} + +function groupToDic(groupId) { + const group = groupsColl.document("groups/" + groupId); + return { + id: group._key, + members: groupMembers(group._key), + type: group.type || "general", + founders: (group.founders || []).map((founder) => + founder.replace("users/", "") + ), + admins: group.admins || group.founders, + isNew: group.isNew, + // score on group is deprecated and will be removed on v6 + score: 0, + url: group.url, + timestamp: group.timestamp, + }; +} + +function userGroups(userId) { + return usersInGroupsColl + .byExample({ + _from: "users/" + userId, + }) + .toArray() + .map((ug) => { + return { + id: ug._to.replace("groups/", ""), + timestamp: ug.timestamp, + }; + }); +} + +function userInvitedGroups(userId) { + return invitationsColl + .byExample({ + _from: "users/" + userId, + }) + .toArray() + .filter((invite) => { + return Date.now() - invite.timestamp < 86400000; + }) + .map((invite) => { + let group = groupToDic(invite._to.replace("groups/", "")); + group.inviter = invite.inviter; + group.inviteId = invite._key; + group.data = invite.data; + group.invited = invite.timestamp; + return group; + }); +} + +function invite(inviter, invitee, groupId, data, timestamp) { + if (!groupsColl.exists(groupId)) { + throw new errors.GroupNotFoundError(groupId); + } + const group = groupsColl.document(groupId); + if (!group.admins || !group.admins.includes(inviter)) { + throw new errors.NotAdminError(); + } + if (group.type == "primary" && hasPrimaryGroup(invitee)) { + throw new errors.AlreadyHasPrimaryGroupError(); + } + if (group.isNew && !group.founders.includes(invitee)) { + throw new errors.NewUserBeforeFoundersJoinError(); + } + invitationsColl.removeByExample({ + _from: "users/" + invitee, + _to: "groups/" + groupId, + }); + invitationsColl.insert({ + _from: "users/" + invitee, + _to: "groups/" + groupId, + inviter, + data, + timestamp, + }); +} + +function dismiss(dismisser, dismissee, groupId, timestamp) { + if (!groupsColl.exists(groupId)) { + throw new errors.GroupNotFoundError(groupId); + } + const group = groupsColl.document(groupId); + if (!group.admins || !group.admins.includes(dismisser)) { + throw new errors.NotAdminError(); + } + deleteMembership(groupId, dismissee, timestamp); +} + +function loadUser(id) { + return query`RETURN DOCUMENT(${usersColl}, ${id})`.toArray()[0]; +} + +function userScore(key) { + return query` + FOR u in ${usersColl} + FILTER u._key == ${key} + RETURN u.score + `.toArray()[0]; +} + +function createUser(key, timestamp) { + // already exists? + const user = loadUser(key); + + if (!user) { + return usersColl.insert({ + score: 0, + signingKeys: [urlSafeB64ToB64(key)], + createdAt: timestamp, + _key: key, + }); + } else { + return user; + } +} + +function hasPrimaryGroup(key) { + const groupIds = usersInGroupsColl + .byExample({ + _from: "users/" + key, + }) + .toArray() + .map((ug) => ug._to.replace("groups/", "")); + const groups = groupsColl.documents(groupIds).documents; + return groups.filter((group) => group.type == "primary").length > 0; +} + +function createGroup( + groupId, + key1, + key2, + inviteData2, + key3, + inviteData3, + url, + type, + timestamp +) { + if (!["general", "primary"].includes(type)) { + throw new errors.InvalidGroupTypeError(type); + } + + if (groupsColl.exists(groupId)) { + throw new errors.DuplicateGroupError(); + } + + const conns = connectionsColl + .byExample({ + _to: "users/" + key1, + }) + .toArray() + .map((u) => u._from.replace("users/", "")); + if (conns.indexOf(key2) < 0 || conns.indexOf(key3) < 0) { + throw new errors.InvalidCoFoundersError(); + } + + const founders = [key1, key2, key3].sort(); + if (type == "primary" && founders.some(hasPrimaryGroup)) { + throw new errors.AlreadyHasPrimaryGroupError(); + } + + groupsColl.insert({ + _key: groupId, + score: 0, + isNew: true, + admins: founders, + url, + type, + timestamp, + founders, + }); + + // Add the creator and invite other cofounders to the group now. + // The other two "co-founders" have to join using /membership + addUserToGroup(groupId, key1, timestamp); + invite(key1, key2, groupId, inviteData2, timestamp); + invite(key1, key3, groupId, inviteData3, timestamp); +} + +function addAdmin(key, admin, groupId) { + if (!groupsColl.exists(groupId)) { + throw new errors.GroupNotFoundError(groupId); + } + if ( + !usersInGroupsColl.firstExample({ + _from: "users/" + admin, + _to: "groups/" + groupId, + }) + ) { + throw new errors.IneligibleNewAdminError(); + } + const group = groupsColl.document(groupId); + if (!group.admins || !group.admins.includes(key)) { + throw new errors.NotAdminError(); + } + group.admins.push(admin); + groupsColl.update(group, { admins: group.admins }); +} + +function addUserToGroup(groupId, key, timestamp) { + const user = "users/" + key; + const group = "groups/" + groupId; + + const edge = usersInGroupsColl.firstExample({ + _from: user, + _to: group, + }); + if (!edge) { + usersInGroupsColl.insert({ + _from: user, + _to: group, + timestamp, + }); + } else { + usersInGroupsColl.update(edge, { timestamp }); + } +} + +function addMembership(groupId, key, timestamp) { + if (!groupsColl.exists(groupId)) { + throw new errors.GroupNotFoundError(groupId); + } + + const group = groupsColl.document(groupId); + if (group.isNew && !group.founders.includes(key)) { + throw new errors.NewUserBeforeFoundersJoinError(); + } + + if (group.type == "primary" && hasPrimaryGroup(key)) { + throw new errors.AlreadyHasPrimaryGroupError(); + } + + const invite = invitationsColl.firstExample({ + _from: "users/" + key, + _to: "groups/" + groupId, + }); + // invites will expire after 24 hours + if (!invite || timestamp - invite.timestamp >= 86400000) { + throw new errors.NotInvitedError(); + } + // remove invite after joining to not allow reusing that + invitationsColl.remove(invite); + + addUserToGroup(groupId, key, timestamp); + + if (groupMembers(groupId).length == group.founders.length) { + groupsColl.update(group, { isNew: false }); + } + updateEligibles(groupId); +} + +function deleteGroup(groupId, key, timestamp) { + if (!groupsColl.exists(groupId)) { + throw new errors.GroupNotFoundError(groupId); + } + + const group = groupsColl.document(groupId); + if (group.admins.indexOf(key) < 0) { + throw new errors.NotAdminError(); + } + + invitationsColl.removeByExample({ _to: "groups/" + groupId }); + usersInGroupsColl.removeByExample({ _to: "groups/" + groupId }); + groupsColl.remove(group); +} + +function deleteMembership(groupId, key, timestamp) { + if (!groupsColl.exists(groupId)) { + throw new errors.GroupNotFoundError(groupId); + } + const group = groupsColl.document(groupId); + if (group.admins && group.admins.includes(key)) { + const admins = group.admins.filter((admin) => key != admin); + const members = groupMembers(groupId); + if (admins.length == 0 && members.length > 1) { + throw new errors.LeaveGroupError(); + } + groupsColl.update(group, { admins }); + } + usersInGroupsColl.removeByExample({ + _from: "users/" + key, + _to: "groups/" + groupId, + }); +} + +function getContext(context) { + if (!contextsColl.exists(context)) { + throw new errors.ContextNotFoundError(context); + } + return contextsColl.document(context); +} + +function getApp(app) { + if (!appsColl.exists(app)) { + throw new errors.AppNotFoundError(app); + } + return appsColl.document(app); +} + +function getApps() { + return appsColl.all().toArray(); +} + +function appToDic(app) { + return { + id: app._key, + name: app.name, + context: app.context, + verification: app.verification, + verificationUrl: app.verificationUrl, + logo: app.logo, + url: app.url, + assignedSponsorships: app.totalSponsorships, + unusedSponsorships: app.totalSponsorships - (app.usedSponsorships || 0), + testing: app.testing, + soulbound: app.soulbound, + soulboundMessage: + app.context && contextsColl.exists(app.context) + ? contextsColl.document(app.context).soulboundMessage || "" + : "", + }; +} + +function getUserByContextId(coll, contextId) { + return query` + FOR l in ${coll} + FILTER l.contextId == ${contextId} + RETURN l.user + `.toArray()[0]; +} + +function getContextIdsByUser(coll, id) { + return query` + FOR u in ${coll} + FILTER u.user == ${id} + SORT u.timestamp DESC + RETURN u.contextId + `.toArray(); +} + +function getLastContextIds(appKey, countOnly) { + const { context, verification } = getApp(appKey); + const { collection } = getContext(context); + const coll = db._collection(collection); + + const baseQuery = aql` + FOR c IN ${coll} + FOR s IN ${sponsorshipsColl} + FILTER s._from == CONCAT("users/", c.user) OR (s.appId == c.contextId AND s.appHasAuthorized AND s.spendRequested) + FOR v in verifications + FILTER v.user == c.user AND v.expression == true AND v.name == ${verification} + `; + const data = {}; + if (countOnly) { + data["count"] = db + ._query( + aql` + ${baseQuery} + COLLECT user = c.user + COLLECT WITH COUNT INTO length + RETURN length + ` + ) + .toArray()[0]; + } else { + data["contextIds"] = db + ._query( + aql` + ${baseQuery} + SORT c.timestamp DESC + COLLECT user = c.user INTO contextIds = c.contextId + RETURN contextIds[0] + ` + ) + .toArray(); + data["count"] = data["contextIds"].length; + } + return data; +} + +function userVerifications(user) { + let hashes = variablesColl.document("VERIFICATIONS_HASHES").hashes; + hashes = JSON.parse(hashes); + // const snapshotPeriod = hashes[1]['block'] - hashes[0]['block'] + // const lastBlock = variablesColl.document('LAST_BLOCK').value; + // // We want verifications from the second-most recently generated snapshot + // // prior to LAST_BLOCK. We use this approach to ensure all synced nodes return + // // verifications from same block regardless of how fast they are in processing + // // new generated snapshots and adding new verifications to database. + // let block; + // if (lastBlock > hashes[1]['block'] + snapshotPeriod) { + // block = hashes[1]['block']; + // } else { + // block = hashes[0]['block']; + // } + + // rollback consneus based block selection consneus temporarily to ensure faster verification + let block = Math.max(...Object.keys(hashes)); + + const verifications = verificationsColl.byExample({ user, block }).toArray(); + verifications.forEach((v) => { + delete v._key; + delete v._id; + delete v._rev; + delete v.user; + }); + + // replace expression based verification with app based ones + getApps().forEach((app) => { + const v = verifications.find( + (v) => v.expression && app.verification == v.name + ); + if (v) { + verifications.push({ + app: true, + name: app._key, + timestamp: v.timestamp, + block: v.block, + }); + } + }); + return verifications.filter((v) => !v.expression); +} + +function linkContextId(id, context, contextId, timestamp) { + const { collection, idsAsHex, soulbound, soulboundMessage } = + getContext(context); + + if (!loadUser(id)) { + throw new errors.UserNotFoundError(id); + } + + if (!contextId) { + throw new errors.InvalidContextIdError(contextId); + } + + if (soulboundMessage) { + if (!isEthereumSignature(contextId)) { + throw new errors.InvalidContextIdError(contextId); + } + contextId = recoverEthSigner(contextId, soulboundMessage); + } else if (idsAsHex) { + if (!isEthereumAddress(contextId)) { + throw new errors.InvalidContextIdError(contextId); + } + contextId = contextId.toLowerCase(); + } else if (soulbound) { + if (contextId.length > 32) { + throw new errors.InvalidContextIdError(contextId); + } + } + + // remove testblocks if exists + removeTestblock(contextId, "link"); + + const coll = db._collection(collection); + let user = getUserByContextId(coll, contextId); + if (user && user != id) { + throw new errors.DuplicateContextIdError(contextId); + } + + const appsWithSameContext = query` + FOR app in apps + FILTER app.context == ${context} + return app + `.toArray(); + let verified = false; + for (let app of appsWithSameContext) { + if (isVerifiedFor(id, app.verification)) { + verified = true; + break; + } + } + if (!verified) { + throw new errors.NotVerifiedError(context); + } + + const links = coll.byExample({ user: id }).toArray(); + const recentLinks = links.filter( + (link) => timestamp - link.timestamp < 24 * 3600 * 1000 + ); + if (recentLinks.length >= 3) { + throw new errors.TooManyLinkRequestError(); + } + + // accept link if the contextId is used by the same user before + for (let link of links) { + if (link.contextId === contextId) { + if (timestamp > link.timestamp) { + coll.update(link, { timestamp }); + } + return; + } + } + + coll.insert({ + user: id, + contextId, + timestamp, + }); +} + +function setRecoveryConnections(conns, key, timestamp) { + // this function is deprecated and will be removed on v6 + conns.forEach((conn) => { + connect({ + id1: key, + id2: conn, + level: "recovery", + timestamp, + }); + }); +} + +function getRecoveryPeriods(allConnections, user, now) { + const recoveryPeriods = []; + const history = allConnections.filter((c) => c.id == user); + let open = false; + let period = {}; + for (let i = 0; i < history.length; i++) { + if (history[i].level == "recovery" && !open) { + open = true; + period["start"] = history[i].timestamp; + } else if (history[i].level != "recovery" && open) { + period["end"] = history[i].timestamp; + recoveryPeriods.push(period); + period = {}; + open = false; + } + } + if (open) { + period["end"] = now; + recoveryPeriods.push(period); + } + return recoveryPeriods; +} + +function getRecoveryConnections(user) { + const res = []; + const allConnections = connectionsHistoryColl + .byExample({ + _from: "users/" + user, + }) + .toArray() + .map((c) => { + return { + id: c._to.replace("users/", ""), + level: c.level, + timestamp: c.timestamp, + }; + }); + allConnections.sort((c1, c2) => c1.timestamp - c2.timestamp); + const recoveryConnections = allConnections.filter( + (conn) => conn.level == "recovery" + ); + if (recoveryConnections.length == 0) { + return res; + } + + const now = Date.now(); + const firstDayBorder = recoveryConnections[0].timestamp + 24 * 60 * 60 * 1000; + const aWeek = 7 * 24 * 60 * 60 * 1000; + const aWeekBorder = Date.now() - aWeek; + const recoveryIds = new Set(recoveryConnections.map((conn) => conn.id)); + + // 1) New recovery connections can participate in resetting signing key, + // one week after being set as recovery connection. This limit is not + // applied to recovery connections that users set for the first time. + // 2) Removed recovery connections can continue participating in resetting + // signing key, for one week after being removed from recovery connections + for (let id of recoveryIds) { + // find the periods that this user was recovery + const recoveryPeriods = getRecoveryPeriods(allConnections, id, now); + // find this user is recovery now + for (const period of recoveryPeriods) { + if ( + period.end > aWeekBorder && + (period.end - period.start > aWeek || period.start < firstDayBorder) + ) { + res.push(id); + } + } + } + return res; +} + +function setSigningKey(signingKey, key, timestamp) { + usersColl.update(key, { + signingKeys: [signingKey], + updateTime: timestamp, + }); + + // remove pending invites, because they can not be decrypted anymore by the new signing key + invitationsColl.removeByExample({ + _from: "users/" + key, + }); +} + +function isSponsored(key) { + return sponsorshipsColl.firstExample({ _from: "users/" + key }) != null; +} + +function getSponsorship(contextId) { + const sponsorship = sponsorshipsColl.firstExample({ appId: contextId }); + if (!sponsorship) { + throw new errors.NotSponsoredError(contextId); + } + return sponsorship; +} + +function sponsor(op) { + const app = appsColl.document(op.app); + + if (op.id) { + //check app verifications and user verifications + if (!isVerifiedFor(op.id, app.verification)) { + throw new errors.NotVerifiedError(app.context, app._key); + } + const sponsorship = sponsorshipsColl.firstExample({ + _to: `apps/${op.app}`, + _from: `users/${op.id}`, + }); + if (sponsorship) { + throw new errors.SponsoredBeforeError(); + } + + sponsorshipsColl.insert({ + _from: `users/${op.id}`, + _to: "apps/" + op.app, + appHasAuthorized: true, + timestamp: op.timestamp, + appId: null + }); + appsColl.update(app, { usedSponsorships: (app.usedSponsorships || 0) + 1 }); + + //! TODO: deprecated + } + else { + if ( + op.name == "Sponsor" && + app.totalSponsorships - (app.usedSponsorships || 0) < 1 + ) { + throw new errors.UnusedSponsorshipsError(op.app); + } + + const contextId = app.idsAsHex ? op.contextId.toLowerCase() : op.contextId; + // remove testblocks if exists + removeTestblock(contextId, "sponsorship", op.app); + + const sponsorship = sponsorshipsColl.firstExample({ + appId: contextId, + _to: "apps/" + op.app, + }); + if (!sponsorship) { + sponsorshipsColl.insert({ + _from: "users/0", + _to: "apps/" + op.app, + expireDate: Math.ceil(Date.now() / 1000 + 60 * 60), + appId: contextId, + appHasAuthorized: op.name == "Sponsor", + spendRequested: op.name == "Spend Sponsorship", + timestamp: op.timestamp, + }); + return; + } + + if (sponsorship.appHasAuthorized && sponsorship.spendRequested) { + throw new errors.SponsoredBeforeError(); + } + + if (op.name == "Sponsor" && sponsorship.appHasAuthorized) { + throw new errors.AppAuthorizedBeforeError(); + } + + if (op.name == "Spend Sponsorship" && sponsorship.spendRequested) { + throw new errors.SpendRequestedBeforeError(); + } + + sponsorshipsColl.update(sponsorship, { + expireDate: null, + appHasAuthorized: true, + spendRequested: true, + timestamp: op.timestamp, + }); + + appsColl.update(app, { usedSponsorships: (app.usedSponsorships || 0) + 1 }); + } +} + +function loadOperation(key) { + return query`RETURN DOCUMENT(${operationsColl}, ${key})`.toArray()[0]; +} + +function upsertOperation(op) { + if (!operationsColl.exists(op.hash)) { + op._key = op.hash; + operationsColl.insert(op); + } else { + operationsColl.replace(op.hash, op); + } +} + +function getState() { + const lastProcessedBlock = variablesColl.document("LAST_BLOCK").value; + const verificationsBlock = variablesColl.document("VERIFICATION_BLOCK").value; + const initOp = query` + FOR o in ${operationsColl} + FILTER o.state == "init" + COLLECT WITH COUNT INTO length + RETURN length + `.toArray()[0]; + const sentOp = query` + FOR o in ${operationsColl} + FILTER o.state == "sent" + COLLECT WITH COUNT INTO length + RETURN length + `.toArray()[0]; + const verificationsHashes = JSON.parse( + variablesColl.document("VERIFICATIONS_HASHES").hashes + ); + const consensusSenderAddress = getConsensusSenderAddress(); + const { privateKey: ethPrivateKey } = getEthKeyPair(); + const { publicKey: naclSigningKey } = getNaclKeyPair(); + return { + lastProcessedBlock, + verificationsBlock, + initOp, + sentOp, + verificationsHashes, + ethSigningAddress: priv2addr(ethPrivateKey), + naclSigningKey, + consensusSenderAddress, + version: module.context.manifest.version, + }; +} + +function addTestblock(contextId, action, app) { + testblocksColl.insert({ app, contextId, action, timestamp: Date.now() }); +} + +function removeTestblock(contextId, action, app) { + let query; + if (app) { + query = { app, contextId, action }; + } else { + query = { contextId, action }; + } + testblocksColl.removeByExample(query); +} + +function getTestblocks(app, contextId) { + return testblocksColl + .byExample({ + app: app, + contextId: contextId, + }) + .toArray() + .map((b) => b.action); +} + +function getContextIds(coll) { + return coll + .all() + .toArray() + .map((c) => { + return { + user: c.user, + contextId: c.contextId, + timestamp: c.timestamp, + }; + }); +} + +function loadGroup(groupId) { + return query`RETURN DOCUMENT(${groupsColl}, ${groupId})`.toArray()[0]; +} + +function groupInvites(groupId) { + return invitationsColl + .byExample({ + _to: "groups/" + groupId, + }) + .toArray() + .filter((invite) => { + return Date.now() - invite.timestamp < 86400000; + }) + .map((invite) => { + return { + inviter: invite.inviter, + invitee: invite._from.replace("users/", ""), + id: invite._key, + data: invite.data, + timestamp: invite.timestamp, + }; + }); +} + +function removePasscode(contextKey) { + contextsColl.update(contextKey, { + passcode: null, + }); +} + +function updateGroup(admin, groupId, url, timestamp) { + if (!groupsColl.exists(groupId)) { + throw new errors.GroupNotFoundError(groupId); + } + const group = groupsColl.document(groupId); + if (!group.admins || !group.admins.includes(admin)) { + throw new errors.NotAdminError(); + } + groupsColl.update(group, { + url, + timestamp, + }); +} + +function addSigningKey(id, signingKey, timestamp) { + const signingKeys = usersColl.document(id).signingKeys || []; + if (signingKeys.indexOf(signingKey) == -1) { + signingKeys.push(signingKey); + usersColl.update(id, { signingKeys }); + } +} + +function removeSigningKey(id, signingKey) { + let signingKeys = usersColl.document(id).signingKeys || []; + signingKeys = signingKeys.filter((s) => s != signingKey); + usersColl.update(id, { signingKeys }); +} + +function removeAllSigningKeys(id, signingKey) { + let signingKeys = usersColl.document(id).signingKeys || []; + signingKeys = signingKeys.filter((s) => s == signingKey); + usersColl.update(id, { signingKeys }); +} + +function isEthereumAddress(address) { + const re = new RegExp(/^0[xX][A-Fa-f0-9]{40}$/); + return re.test(address); +} + +function isEthereumSignature(sig) { + const re = new RegExp(/^[A-Fa-f0-9]{130}$/); + return re.test(sig); +} + +function sponsorRequestedRecently(op) { + const lastSponsorTimestamp = query` + FOR o in ${operationsColl} + FILTER o.name == "Sponsor" + AND o.contextId IN ${[op.contextId, op.contextId.toLowerCase()]} + SORT o.timestamp ASC + RETURN o.timestamp + ` + .toArray() + .pop(); + + const timeWindow = module.context.configuration.operationsTimeWindow * 1000; + return lastSponsorTimestamp && Date.now() - lastSponsorTimestamp < timeWindow; +} + +function isSponsoredByContextId(op) { + const sponsored = query` + FOR s in ${sponsorshipsColl} + FILTER s._to == CONCAT("apps/", ${op.app}) + AND s.appHasAuthorized == true + AND s.spendRequested == true + AND s.appId IN ${[op.contextId, op.contextId.toLowerCase()]} + RETURN s.appId + ` + .toArray() + .pop(); + if (sponsored) { + return true; + } + + return false; +} + +function isVerifiedFor(user, verification) { + let verifications = userVerifications(user); + verifications = _.keyBy(verifications, (v) => v.name); + let verified; + try { + let expr = parser.parse(verification); + for (let v of expr.variables()) { + if (!verifications[v]) { + verifications[v] = false; + } + } + verified = expr.evaluate(verifications); + } catch (err) { + return false; + } + return verified; +} + +module.exports = { + connect, + addConnection, + removeConnection, + createGroup, + deleteGroup, + addAdmin, + addMembership, + deleteMembership, + updateEligibleGroups, + invite, + dismiss, + userConnections, + userGroups, + loadUser, + userInvitedGroups, + createUser, + groupMembers, + userScore, + getContext, + getApp, + getApps, + appToDic, + userVerifications, + getUserByContextId, + getContextIdsByUser, + sponsor, + isSponsored, + getSponsorship, + linkContextId, + loadOperation, + upsertOperation, + setRecoveryConnections, + setSigningKey, + getLastContextIds, + getState, + getReporters, + getRecoveryConnections, + userToDic, + groupToDic, + addTestblock, + removeTestblock, + getTestblocks, + addSigningKey, + removeSigningKey, + removeAllSigningKeys, + getContextIds, + removePasscode, + loadGroup, + groupInvites, + updateEligibles, + updateGroup, + isEthereumAddress, + sponsorRequestedRecently, + isSponsoredByContextId, + isVerifiedFor, +}; diff --git a/web_services/foxx/apply5/encoding.js b/web_services/foxx/apply5/encoding.js new file mode 100755 index 00000000..0d6ccd6c --- /dev/null +++ b/web_services/foxx/apply5/encoding.js @@ -0,0 +1,161 @@ +"use strict"; +const B64 = require("base64-js"); +const crypto = require("@arangodb/crypto"); +const nacl = require("tweetnacl"); +const secp256k1 = require("secp256k1"); +const createKeccakHash = require("keccak"); + +const conf = module.context.configuration; + +function uInt8ArrayToB64(array) { + const b = Buffer.from(array); + return b.toString("base64"); +} + +function b64ToUint8Array(str) { + // B64.toByteArray might return a Uint8Array, an Array or an Object depending on the platform. + // Wrap it in Object.values and new Uint8Array to make sure it's a Uint8Array. + return new Uint8Array(Object.values(B64.toByteArray(str))); +} + +function strToUint8Array(str) { + return new Uint8Array(Buffer.from(str, "ascii")); +} + +function b64ToUrlSafeB64(s) { + const alts = { + "/": "_", + "+": "-", + "=": "", + }; + return s.replace(/[/+=]/g, (c) => alts[c]); +} + +function urlSafeB64ToB64(s) { + const alts = { + _: "/", + "-": "+", + }; + s = s.replace(/[-_]/g, (c) => alts[c]); + while (s.length % 4) { + s += "="; + } + return s; +} + +function hash(data) { + const h = crypto.sha256(data); + const b = Buffer.from(h, "hex").toString("base64"); + return b64ToUrlSafeB64(b); +} + +function pad32(data) { + return data + String.fromCharCode(0).repeat(32 - data.length); +} + +function addressToBytes32(address) { + const b = Buffer.from(address.substring(2), "hex").toString("binary"); + return String.fromCharCode(0).repeat(12) + b; +} + +function priv2addr(priv) { + if (!priv) { + return null; + } + const publicKey = Buffer.from( + Object.values(secp256k1.publicKeyCreate(priv, false).slice(1)) + ); + return ( + "0x" + + createKeccakHash("keccak256") + .update(publicKey) + .digest() + .slice(-20) + .toString("hex") + ); +} + +function getNaclKeyPair() { + let publicKey, privateKey; + if (conf.privateKey) { + publicKey = uInt8ArrayToB64( + Object.values( + nacl.sign.keyPair.fromSecretKey(b64ToUint8Array(conf.privateKey)) + .publicKey + ) + ); + privateKey = b64ToUint8Array(conf.privateKey); + } else if (conf.seed) { + const hex32 = crypto.sha256(conf.seed); + const uint8Array = new Uint8Array(Buffer.from(hex32, "hex")); + const naclKeyPair = nacl.sign.keyPair.fromSeed(uint8Array); + publicKey = uInt8ArrayToB64(Object.values(naclKeyPair.publicKey)); + privateKey = naclKeyPair.secretKey; + } + return { publicKey, privateKey }; +} + +function getEthKeyPair() { + let publicKey, privateKey; + if (conf.ethPrivateKey) { + privateKey = new Uint8Array(Buffer.from(conf.ethPrivateKey, "hex")); + publicKey = Buffer.from( + Object.values(secp256k1.publicKeyCreate(privateKey)) + ).toString("hex"); + } else if (conf.seed) { + const hex32 = crypto.sha256(conf.seed); + privateKey = new Uint8Array(Buffer.from(hex32, "hex")); + publicKey = Buffer.from( + Object.values(secp256k1.publicKeyCreate(privateKey)) + ).toString("hex"); + } + return { publicKey, privateKey }; +} + +function getConsensusSenderAddress() { + let address = null; + if (conf.consensusSenderPrivateKey) { + const uint8ArrayPrivateKey = new Uint8Array( + Buffer.from(conf.consensusSenderPrivateKey, "hex") + ); + address = priv2addr(uint8ArrayPrivateKey); + } else if (conf.seed) { + const hex32 = crypto.sha256(conf.seed); + const uint8ArrayPrivateKey = new Uint8Array(Buffer.from(hex32, "hex")); + address = priv2addr(uint8ArrayPrivateKey); + } + return address; +} + +function recoverEthSigner(signature, message) { + message = `\x19Ethereum Signed Message:\n${message.length}${message}`; + const hexMessage = Buffer.from(message, "binary").toString("hex"); + const hashMessage = new Uint8Array( + createKeccakHash("keccak256").update(hexMessage, "hex").digest() + ); + const recid = parseInt(signature.slice(-2), 16) - 27; + signature = new Uint8Array(Buffer.from(signature.slice(0, -2), "hex")); + let publicKey = secp256k1.ecdsaRecover(signature, recid, hashMessage); + publicKey = Buffer.from( + secp256k1.publicKeyConvert(publicKey, false).buffer + ).slice(1); + publicKey = publicKey.toString("hex"); + publicKey = createKeccakHash("keccak256").update(publicKey, "hex").digest(); + return `0x${publicKey.slice(-20).toString("hex")}`; +} + +module.exports = { + uInt8ArrayToB64, + b64ToUint8Array, + strToUint8Array, + b64ToUrlSafeB64, + urlSafeB64ToB64, + hash, + pad32, + addressToBytes32, + priv2addr, + getNaclKeyPair, + getEthKeyPair, + getConsensusSenderAddress, + recoverEthSigner, +}; diff --git a/web_services/foxx/apply5/errors.js b/web_services/foxx/apply5/errors.js new file mode 100755 index 00000000..3343cc74 --- /dev/null +++ b/web_services/foxx/apply5/errors.js @@ -0,0 +1,576 @@ +const CONTEXT_NOT_FOUND = 1; +const CONTEXTID_NOT_FOUND = 2; +const NOT_VERIFIED = 3; +const NOT_SPONSORED = 4; +const KEYPAIR_NOT_SET = 6; +const ETHPRIVATEKEY_NOT_SET = 7; +const OPERATION_NOT_FOUND = 9; +const USER_NOT_FOUND = 10; +const IP_NOT_SET = 11; +const APP_NOT_FOUND = 12; +const INVALID_EXPRESSION = 13; +const INVALID_TESTING_KEY = 14; +const INVALID_PASSCODE = 15; +const PASSCODE_NOT_SET = 16; +const GROUP_NOT_FOUND = 17; +const INVALID_OPERATION_NAME = 18; +const INVALID_SIGNATURE = 19; +const TOO_MANY_OPERATIONS = 20; +const INVALID_OPERATION_VERSION = 21; +const INVALID_TIMESTAMP = 22; +const NOT_RECOVERY_CONNECTIONS = 23; +const INVALID_HASH = 24; +const OPERATION_APPLIED_BEFORE = 25; +const TOO_BIG_OPERATION = 26; +const INELIGIBLE_NEW_USER = 27; +const ALREADY_HAS_PRIMARY_GROUP = 28; +const NEW_USER_BEFORE_FOUNDERS_JOIN = 29; +const INVALID_GROUP_TYPE = 30; +const DUPLICATE_GROUP = 31; +const INVALID_COFOUNDERS = 32; +const INELIGIBLE_NEW_ADMIN = 33; +const NOT_INVITED = 34; +const LEAVE_GROUP = 35; +const DUPLICATE_CONTEXTID = 36; +const TOO_MANY_LINK_REQUEST = 37; +const UNUSED_SPONSORSHIPS = 38; +const SPONSORED_BEFORE = 39; +const SPONSOR_NOT_SUPPORTED = 40; +const NOT_ADMIN = 41; +const ARANGO_ERROR = 42; +const INELIGIBLE_RECOVERY_CONNECTION = 43; +const INVALID_CONTEXTID = 44; +const APP_AUTHORIZED_BEFORE = 45; +const SPEND_REQUESTED_BEFORE = 46; +const SPONSOR_REQUESTED_RECENTLY = 47; + +class BrightIDError extends Error { + constructor() { + super(); + this.name = "BrightIDError"; + this.date = new Date(); + } +} + +class BadRequestError extends BrightIDError { + constructor() { + super(); + this.code = 400; + this.message = "Bad Request"; + } +} + +class UnauthorizedError extends BrightIDError { + constructor() { + super(); + this.code = 401; + this.message = "Unauthorized"; + } +} + +class ForbiddenError extends BrightIDError { + constructor() { + super(); + this.code = 403; + this.message = "Forbidden"; + } +} + +class NotFoundError extends BrightIDError { + constructor() { + super(); + this.code = 404; + this.message = "Not Found"; + } +} + +class TooManyRequestsError extends BrightIDError { + constructor() { + super(); + this.code = 429; + this.message = "Too Many Requests"; + } +} + +class InternalServerError extends BrightIDError { + constructor() { + super(); + this.code = 500; + this.message = "Internal Server Error"; + } +} + +class InvalidSignatureError extends UnauthorizedError { + constructor() { + super(); + this.errorNum = INVALID_SIGNATURE; + this.message = "Signature is not valid."; + } +} + +class AppNotFoundError extends NotFoundError { + constructor(app) { + super(); + this.errorNum = APP_NOT_FOUND; + this.message = `${app} app is not found.`; + this.app = app; + } +} + +class TooManyOperationsError extends TooManyRequestsError { + constructor(senders, waitingTime, timeWindow, limit) { + super(); + this.errorNum = TOO_MANY_OPERATIONS; + this.message = `More than ${limit} operations sent from ${senders.join( + ", " + )} in ${parseInt(timeWindow / 1000)} seconds. Try again after ${parseInt( + waitingTime / 1000 + )} seconds.`; + this.senders = senders; + this.waitingTime = waitingTime; + this.timeWindow = timeWindow; + this.limit = limit; + } +} + +class InvalidOperationNameError extends BadRequestError { + constructor(name) { + super(); + this.errorNum = INVALID_OPERATION_NAME; + this.message = `${name} is not a valid operation name.`; + this.name = name; + } +} + +class InvalidOperationVersionError extends BadRequestError { + constructor(v) { + super(); + this.errorNum = INVALID_OPERATION_VERSION; + this.message = `${v} is not a valid operation version.`; + this.v = v; + } +} + +class InvalidOperationTimestampError extends ForbiddenError { + constructor(timestamp) { + super(); + this.errorNum = INVALID_TIMESTAMP; + this.message = `The timestamp (${timestamp}) is in the future.`; + this.timestamp = timestamp; + } +} + +class InvalidOperationHashError extends BadRequestError { + constructor() { + super(); + this.errorNum = INVALID_HASH; + this.message = "Operation hash is not valid."; + } +} + +class NotRecoveryConnectionsError extends ForbiddenError { + constructor() { + super(); + this.errorNum = NOT_RECOVERY_CONNECTIONS; + this.message = "Signers of the request are not recovery connections."; + } +} + +class OperationNotFoundError extends NotFoundError { + constructor(hash) { + super(); + this.errorNum = OPERATION_NOT_FOUND; + this.message = `The operation ${hash} is not found.`; + this.hash = hash; + } +} + +class OperationAppliedBeforeError extends ForbiddenError { + constructor(hash) { + super(); + this.errorNum = OPERATION_APPLIED_BEFORE; + this.message = `The Operation ${hash} was applied before.`; + this.hash = hash; + } +} + +class TooBigOperationError extends ForbiddenError { + constructor(limit) { + super(); + this.errorNum = TOO_BIG_OPERATION; + this.message = `The Operation is bigger than ${limit} bytes limit.`; + this.limit = limit; + } +} + +class UserNotFoundError extends NotFoundError { + constructor(user) { + super(); + this.errorNum = USER_NOT_FOUND; + this.message = `The user ${user} is not found.`; + this.user = user; + } +} + +class ContextNotFoundError extends NotFoundError { + constructor(context) { + super(); + this.errorNum = CONTEXT_NOT_FOUND; + this.message = `The context ${context} is not found.`; + this.context = context; + } +} + +class ContextIdNotFoundError extends NotFoundError { + constructor(contextId) { + super(); + this.errorNum = CONTEXTID_NOT_FOUND; + this.message = `The contextId ${contextId} is not linked.`; + this.contextId = contextId; + } +} + +class GroupNotFoundError extends NotFoundError { + constructor(group) { + super(); + this.errorNum = GROUP_NOT_FOUND; + this.message = `The group ${group} is not found.`; + this.group = group; + } +} + +class NotSponsoredError extends ForbiddenError { + constructor(contextId) { + super(); + this.errorNum = NOT_SPONSORED; + this.message = `The user linked to the contextId ${contextId} is not sponsored.`; + this.contextId = contextId; + } +} + +class NotVerifiedError extends ForbiddenError { + constructor(contextId, app) { + super(); + this.errorNum = NOT_VERIFIED; + this.message = `The linked user is not verified for ${app} app.`; + this.contextId = contextId; + this.app = app; + } +} + +class InvalidExpressionError extends InternalServerError { + constructor(app, expression, err) { + super(); + this.errorNum = INVALID_EXPRESSION; + this.message = `Evaluating verification expression for ${app} app failed. Expression: "${expression}", Error: ${err}`; + } +} + +class KeypairNotSetError extends InternalServerError { + constructor() { + super(); + this.errorNum = KEYPAIR_NOT_SET; + this.message = + "BN_WS_PUBLIC_KEY or BN_WS_PRIVATE_KEY are not set in config.env."; + } +} + +class EthPrivatekeyNotSetError extends InternalServerError { + constructor() { + super(); + this.errorNum = ETHPRIVATEKEY_NOT_SET; + this.message = "BN_WS_ETH_PRIVATE_KEY is not set."; + } +} + +class IpNotSetError extends InternalServerError { + constructor() { + super(); + this.errorNum = IP_NOT_SET; + this.message = + "BN_WS_IP variable is not set in config.env and is not automatically loaded for an unknown reason."; + } +} + +class InvalidTestingKeyError extends ForbiddenError { + constructor() { + super(); + this.errorNum = INVALID_TESTING_KEY; + this.message = "Invalid testing key."; + } +} + +class PasscodeNotSetError extends ForbiddenError { + constructor(context) { + super(); + this.errorNum = PASSCODE_NOT_SET; + this.message = `Passcode is not set on the remote node for the ${context} context.`; + this.context = context; + } +} + +class InvalidPasscodeError extends ForbiddenError { + constructor() { + super(); + this.errorNum = INVALID_PASSCODE; + this.message = "Invalid passcode."; + } +} + +class NotAdminError extends ForbiddenError { + constructor() { + super(); + this.errorNum = NOT_ADMIN; + this.message = "Requstor is not admin of the group."; + } +} + +class AlreadyHasPrimaryGroupError extends ForbiddenError { + constructor() { + super(); + this.errorNum = ALREADY_HAS_PRIMARY_GROUP; + this.message = "The user already has a primary group."; + } +} + +class NewUserBeforeFoundersJoinError extends ForbiddenError { + constructor() { + super(); + this.errorNum = NEW_USER_BEFORE_FOUNDERS_JOIN; + this.message = "New members can not join before founders join the group."; + } +} + +class InvalidGroupTypeError extends ForbiddenError { + constructor(type) { + super(); + this.errorNum = INVALID_GROUP_TYPE; + this.message = `${type} is not a valid group type.`; + this.type = type; + } +} + +class DuplicateGroupError extends ForbiddenError { + constructor() { + super(); + this.errorNum = DUPLICATE_GROUP; + this.message = "Group with this id already exists."; + } +} + +class InvalidCoFoundersError extends ForbiddenError { + constructor() { + super(); + this.errorNum = INVALID_COFOUNDERS; + this.message = + "One or both of the co-founders are not connected to the founder."; + } +} + +class IneligibleNewAdminError extends ForbiddenError { + constructor() { + super(); + this.errorNum = INELIGIBLE_NEW_ADMIN; + this.message = "New admin is not member of the group."; + } +} + +class NotInvitedError extends ForbiddenError { + constructor() { + super(); + this.errorNum = NOT_INVITED; + this.message = "The user is not invited to join this group."; + } +} + +class LeaveGroupError extends ForbiddenError { + constructor() { + super(); + this.errorNum = LEAVE_GROUP; + this.message = + "Last admin can not leave the group when it still has other members."; + } +} + +class DuplicateContextIdError extends ForbiddenError { + constructor(contextId) { + super(); + this.errorNum = DUPLICATE_CONTEXTID; + this.message = `The contextId ${contextId} is used by another user before.`; + this.contextId = contextId; + } +} + +class TooManyLinkRequestError extends TooManyRequestsError { + constructor() { + super(); + this.errorNum = TOO_MANY_LINK_REQUEST; + this.message = "Only three contextIds can be linked every 24 hours."; + } +} + +class UnusedSponsorshipsError extends ForbiddenError { + constructor(app) { + super(); + this.errorNum = UNUSED_SPONSORSHIPS; + this.message = `${app} app does not have unused sponsorships.`; + this.app = app; + } +} + +class SponsoredBeforeError extends ForbiddenError { + constructor() { + super(); + this.errorNum = SPONSORED_BEFORE; + this.message = "The user has been sponsored before."; + } +} + +class SponsorNotSupportedError extends ForbiddenError { + constructor(app) { + super(); + this.errorNum = SPONSOR_NOT_SUPPORTED; + this.message = `This node can not relay sponsor requests for ${app} app.`; + this.app = app; + } +} + +class IneligibleRecoveryConnection extends ForbiddenError { + constructor() { + super(); + this.errorNum = INELIGIBLE_RECOVERY_CONNECTION; + this.message = + "Recovery level can only be selected for connections that already know you or trust you as their recovery connection."; + } +} + +class InvalidContextIdError extends NotFoundError { + constructor(contextId) { + super(); + this.errorNum = INVALID_CONTEXTID; + this.message = `The contextId ${contextId} is not valid.`; + this.contextId = contextId; + } +} + +class AppAuthorizedBeforeError extends ForbiddenError { + constructor() { + super(); + this.errorNum = APP_AUTHORIZED_BEFORE; + this.message = + "The app authorized a sponsorship for this contextId before."; + } +} + +class SpendRequestedBeforeError extends ForbiddenError { + constructor() { + super(); + this.errorNum = SPEND_REQUESTED_BEFORE; + this.message = "Spend request for this contextId submitted before."; + } +} + +class SponsorRequestedRecently extends ForbiddenError { + constructor() { + super(); + this.errorNum = SPONSOR_REQUESTED_RECENTLY; + this.message = `The app has sent this sponsor request recently.`; + } +} + +module.exports = { + CONTEXT_NOT_FOUND, + CONTEXTID_NOT_FOUND, + NOT_VERIFIED, + NOT_SPONSORED, + KEYPAIR_NOT_SET, + ETHPRIVATEKEY_NOT_SET, + OPERATION_NOT_FOUND, + USER_NOT_FOUND, + IP_NOT_SET, + APP_NOT_FOUND, + INVALID_EXPRESSION, + INVALID_TESTING_KEY, + INVALID_PASSCODE, + PASSCODE_NOT_SET, + GROUP_NOT_FOUND, + INVALID_OPERATION_NAME, + INVALID_SIGNATURE, + TOO_MANY_OPERATIONS, + INVALID_OPERATION_VERSION, + INVALID_TIMESTAMP, + NOT_RECOVERY_CONNECTIONS, + INVALID_HASH, + OPERATION_APPLIED_BEFORE, + TOO_BIG_OPERATION, + ALREADY_HAS_PRIMARY_GROUP, + NEW_USER_BEFORE_FOUNDERS_JOIN, + INVALID_GROUP_TYPE, + DUPLICATE_GROUP, + INVALID_COFOUNDERS, + INELIGIBLE_NEW_ADMIN, + NOT_INVITED, + LEAVE_GROUP, + DUPLICATE_CONTEXTID, + TOO_MANY_LINK_REQUEST, + UNUSED_SPONSORSHIPS, + SPONSORED_BEFORE, + SPONSOR_NOT_SUPPORTED, + NOT_ADMIN, + ARANGO_ERROR, + INELIGIBLE_RECOVERY_CONNECTION, + INVALID_CONTEXTID, + APP_AUTHORIZED_BEFORE, + SPEND_REQUESTED_BEFORE, + SPONSOR_REQUESTED_RECENTLY, + BrightIDError, + BadRequestError, + InternalServerError, + TooManyRequestsError, + UnauthorizedError, + NotFoundError, + ForbiddenError, + InvalidSignatureError, + AppNotFoundError, + TooManyOperationsError, + InvalidOperationNameError, + InvalidOperationVersionError, + InvalidOperationTimestampError, + InvalidOperationHashError, + NotRecoveryConnectionsError, + OperationNotFoundError, + OperationAppliedBeforeError, + TooBigOperationError, + UserNotFoundError, + ContextNotFoundError, + ContextIdNotFoundError, + GroupNotFoundError, + NotSponsoredError, + NotVerifiedError, + InvalidExpressionError, + KeypairNotSetError, + EthPrivatekeyNotSetError, + IpNotSetError, + InvalidTestingKeyError, + PasscodeNotSetError, + InvalidPasscodeError, + NotAdminError, + AlreadyHasPrimaryGroupError, + NewUserBeforeFoundersJoinError, + InvalidGroupTypeError, + DuplicateGroupError, + InvalidCoFoundersError, + IneligibleNewAdminError, + NotInvitedError, + LeaveGroupError, + DuplicateContextIdError, + TooManyLinkRequestError, + UnusedSponsorshipsError, + SponsoredBeforeError, + SponsorNotSupportedError, + IneligibleRecoveryConnection, + InvalidContextIdError, + AppAuthorizedBeforeError, + SpendRequestedBeforeError, + SponsorRequestedRecently, +}; diff --git a/web_services/foxx/apply5/index.js b/web_services/foxx/apply5/index.js new file mode 100755 index 00000000..b2abcbb7 --- /dev/null +++ b/web_services/foxx/apply5/index.js @@ -0,0 +1,778 @@ +"use strict"; +const secp256k1 = require("secp256k1"); +const createKeccakHash = require("keccak"); +const createRouter = require("@arangodb/foxx/router"); +const _ = require("lodash"); +const joi = require("joi"); +const { db: arango, ArangoError } = require("@arangodb"); +const nacl = require("tweetnacl"); +const db = require("./db"); +const schemas = require("./schemas").schemas; +const operations = require("./operations"); +const { + strToUint8Array, + uInt8ArrayToB64, + hash, + pad32, + addressToBytes32, + getNaclKeyPair, + getEthKeyPair, +} = require("./encoding"); +const errors = require("./errors"); + +const router = createRouter(); +module.context.use(router); +const operationsHashesColl = arango._collection("operationsHashes"); + +const MAX_OP_SIZE = 2000; + +const handlers = { + operationsPost: function (req, res) { + const op = req.body; + const message = operations.getMessage(op); + op.hash = hash(message); + + if (operationsHashesColl.exists(op.hash)) { + throw new errors.OperationAppliedBeforeError(op.hash); + } else if (JSON.stringify(op).length > MAX_OP_SIZE) { + throw new errors.TooBigOperationError(MAX_OP_SIZE); + } + + // verify signature + operations.verify(op); + + // allow limited number of operations to be posted in defined time window + const timeWindow = module.context.configuration.operationsTimeWindow * 1000; + const limit = ["Sponsor", "Spend Sponsorship"].includes(op.name) + ? module.context.configuration.appsOperationsLimit + : module.context.configuration.operationsLimit; + operations.checkLimits(op, timeWindow, limit); + + if (op.name == "Link ContextId") { + operations.encrypt(op); + } + + op.state = "init"; + db.upsertOperation(op); + res.send({ + data: { + hash: op.hash, + }, + }); + }, + + operationGet: function (req, res) { + const hash = req.param("hash"); + const op = db.loadOperation(hash); + if (op) { + res.send({ + data: { + state: op.state, + result: op.result, + }, + }); + } else { + throw new errors.OperationNotFoundError(hash); + } + }, + + userGet: function (req, res) { + const id = req.param("id"); + const user = db.loadUser(id); + if (!user) { + throw new errors.UserNotFoundError(id); + } + + const verifications = db.userVerifications(id).map((v) => v.name); + + let connections = db.userConnections(id); + const connectionsMap = _.keyBy(connections, (conn) => conn.id); + connections = connections.map((conn) => { + const u = db.userToDic(conn.id); + u.level = connectionsMap[conn.id].level; + u.reportReason = connectionsMap[conn.id].reportReason; + return u; + }); + + let groups = db.userGroups(id); + groups = groups.map((group) => { + const g = db.groupToDic(group.id); + g.joined = group.timestamp; + return g; + }); + + const invites = db.userInvitedGroups(id); + // this is deprecated and will be removed on v6 + db.updateEligibleGroups(id, connections, groups); + + res.send({ + data: { + score: user.score, + createdAt: user.createdAt, + flaggers: db.getReporters(id), + trusted: db.getRecoveryConnections(id), + invites, + groups, + connections, + verifications, + isSponsored: db.isSponsored(id), + signingKeys: user.signingKeys, + }, + }); + }, + + userConnectionsGet: function (req, res) { + const id = req.param("id"); + const direction = req.param("direction"); + res.send({ + data: { + connections: db.userConnections(id, direction), + }, + }); + }, + + userVerificationsGet: function (req, res) { + const id = req.param("id"); + res.send({ + data: { + verifications: db.userVerifications(id), + }, + }); + }, + + userProfileGet: function (req, res) { + const id = req.param("id"); + const requestor = req.param("requestor"); + const user = db.loadUser(id); + if (!user) { + throw new errors.UserNotFoundError(id); + } + + const verifications = db.userVerifications(id); + const connections = db.userConnections(id, "inbound"); + const groups = db.userGroups(id); + const requestorConnections = db.userConnections(requestor, "outbound"); + const requestorGroups = db.userGroups(requestor); + + const isKnown = (c) => + ["just met", "already known", "recovery"].includes(c.level); + const connectionsNum = connections.filter(isKnown).length; + const groupsNum = groups.length; + const mutualConnections = _.intersection( + connections.filter(isKnown).map((c) => c.id), + requestorConnections.filter(isKnown).map((c) => c.id) + ); + const mutualGroups = _.intersection( + groups.map((g) => g.id), + requestorGroups.map((g) => g.id) + ); + + const conn = connections.find((c) => c.id === requestor); + const connectedAt = conn ? conn.timestamp : 0; + const reports = connections + .filter((c) => c.level === "reported") + .map((c) => { + return { + id: c.id, + reportReason: c.reportReason, + }; + }); + + res.send({ + data: { + connectionsNum, + groupsNum, + mutualConnections, + mutualGroups, + connectedAt, + createdAt: user.createdAt, + reports, + verifications, + signingKeys: user.signingKeys, + }, + }); + }, + + allVerificationsGet: function (req, res) { + const appKey = req.param("app"); + const countOnly = "count_only" in req.queryParams; + const data = db.getLastContextIds(appKey, countOnly); + res.send({ + data, + }); + }, + + verificationGet: function (req, res) { + let unique = true; + let contextId = req.param("contextId"); + let appKey = req.param("app"); + const signed = req.param("signed"); + let timestamp = req.param("timestamp"); + const verification = req.param("verification"); + const app = db.getApp(appKey); + const context = db.getContext(app.context); + if (context.idsAsHex) { + contextId = contextId.toLowerCase(); + } + const testblocks = db.getTestblocks(appKey, contextId); + + if (testblocks.includes("link")) { + throw new errors.ContextIdNotFoundError(contextId); + } else if (testblocks.includes("sponsorship")) { + throw new errors.NotSponsoredError(contextId); + } else if (testblocks.includes("verification")) { + throw new errors.NotVerifiedError(appKey); + } + + const coll = arango._collection(context.collection); + const user = db.getUserByContextId(coll, contextId); + if (!user) { + const sponsorship = db.getSponsorship(contextId); + if (sponsorship.appHasAuthorized) { + throw new errors.ContextIdNotFoundError(contextId); + } else { + throw new errors.NotSponsoredError(contextId); + } + } + + if (!db.isVerifiedFor(user, verification || app.verification)) { + throw new errors.NotVerifiedError(appKey); + } + + let contextIds = db.getContextIdsByUser(coll, user); + if (contextId != contextIds[0]) { + unique = false; + } + + if (timestamp == "seconds") { + timestamp = parseInt(Date.now() / 1000); + } else if (timestamp == "milliseconds") { + timestamp = Date.now(); + } else { + timestamp = undefined; + } + + // sign and return the verification + let sig, publicKey; + if (signed == "nacl") { + const naclKeyPair = getNaclKeyPair(); + if (!naclKeyPair.privateKey) { + throw new errors.KeypairNotSetError(); + } + + let message = appKey + "," + contextIds.join(","); + if (timestamp) { + message = message + "," + timestamp; + } + publicKey = naclKeyPair.publicKey; + sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), naclKeyPair.privateKey) + ) + ); + } else if (signed == "eth") { + const ethKeyPair = getEthKeyPair(); + if (!ethKeyPair.privateKey) { + throw new errors.EthPrivatekeyNotSetError(); + } + + let message, h; + if (context.idsAsHex) { + message = pad32(appKey) + contextIds.map(addressToBytes32).join(""); + } else { + message = pad32(appKey) + contextIds.map(pad32).join(""); + } + message = Buffer.from(message, "binary").toString("hex"); + if (timestamp) { + const t = timestamp.toString(16); + message += "0".repeat(64 - t.length) + t; + } + h = new Uint8Array( + createKeccakHash("keccak256").update(message, "hex").digest() + ); + publicKey = ethKeyPair.publicKey; + const _sig = secp256k1.ecdsaSign(h, ethKeyPair.privateKey); + sig = { + r: Buffer.from(Object.values(_sig.signature.slice(0, 32))).toString( + "hex" + ), + s: Buffer.from(Object.values(_sig.signature.slice(32, 64))).toString( + "hex" + ), + v: _sig.recid + 27, + }; + } + res.send({ + data: { + unique, + app: appKey, + context: app.context, + contextIds: contextIds, + sig, + timestamp, + publicKey, + }, + }); + }, + + ipGet: function (req, res) { + let ip = + module.context && + module.context.configuration && + module.context.configuration.ip; + if (ip) { + res.send({ + data: { + ip, + }, + }); + } else { + throw new errors.IpNotSetError(); + } + }, + + appGet: function (req, res) { + const appKey = req.param("app"); + let app = db.getApp(appKey); + res.send({ + data: db.appToDic(app), + }); + }, + + allAppsGet: function (req, res) { + const apps = db.getApps().map((app) => db.appToDic(app)); + apps.sort((app1, app2) => { + const used1 = app1.assignedSponsorships - app1.unusedSponsorships; + const unused1 = app1.unusedSponsorships; + const used2 = app2.assignedSponsorships - app2.unusedSponsorships; + const unused2 = app2.unusedSponsorships; + return unused2 * used2 - unused1 * used1; + }); + res.send({ + data: { + apps, + }, + }); + }, + + stateGet: function (req, res) { + res.send({ + data: db.getState(), + }); + }, + + testblocksPut: function (req, res) { + const appKey = req.param("app"); + const action = req.param("action"); + let contextId = req.param("contextId"); + const testingKey = req.param("testingKey"); + + const app = db.getApp(appKey); + if (app.testingKey != testingKey) { + throw new errors.InvalidTestingKeyError(); + } + const context = db.getContext(app.context); + if (context.idsAsHex) { + if (!db.isEthereumAddress(contextId)) { + throw new errors.InvalidContextIdError(contextId); + } + contextId = contextId.toLowerCase(); + } + + return db.addTestblock(contextId, action, appKey); + }, + + testblocksDelete: function (req, res) { + const appKey = req.param("app"); + const action = req.param("action"); + let contextId = req.param("contextId"); + const testingKey = req.param("testingKey"); + + const app = db.getApp(appKey); + if (app.testingKey != testingKey) { + throw new errors.InvalidTestingKeyError(); + } + const context = db.getContext(app.context); + if (context.idsAsHex) { + if (!db.isEthereumAddress(contextId)) { + throw new errors.InvalidContextIdError(contextId); + } + contextId = contextId.toLowerCase(); + } + return db.removeTestblock(contextId, action, appKey); + }, + + contextDumpGet: function (req, res) { + const contextKey = req.param("context"); + const passcode = req.queryParams["passcode"]; + const context = db.getContext(contextKey); + + if (!context.passcode) { + throw new errors.PasscodeNotSetError(contextKey); + } + if (context.passcode != passcode) { + throw new errors.InvalidPasscodeError(); + } + + const coll = arango._collection(context.collection); + const contextIds = db.getContextIds(coll); + db.removePasscode(contextKey); + res.send({ + data: { + collection: context.collection, + idsAsHex: context.idsAsHex, + linkAESKey: context.linkAESKey, + contextIds, + }, + }); + }, + + groupGet: function (req, res) { + const id = req.param("id"); + const group = db.loadGroup(id); + if (!group) { + throw new errors.GroupNotFoundError(id); + } + + res.send({ + data: { + members: db.groupMembers(id), + invites: db.groupInvites(id), + // the eligibles is deprecated and will be removed on v6 + eligibles: db.updateEligibles(id), + admins: group.admins, + founders: group.founders, + isNew: group.isNew, + seed: group.seed || false, + region: group.region, + type: group.type || "general", + url: group.url, + info: group.info, + timestamp: group.timestamp, + }, + }); + }, + + sponsorshipGet: function (req, res) { + let appKey = req.param("app"); + let contextId = req.param("contextId"); + if (db.isEthereumAddress(contextId)) { + contextId = contextId.toLowerCase(); + } + const sponsorship = db.getSponsorship(contextId); + res.send({ + data: { + app: sponsorship._to.replace("apps/", ""), + appHasAuthorized: sponsorship.appHasAuthorized, + spendRequested: sponsorship.spendRequested, + timestamp: sponsorship.timestamp, + }, + }); + }, +}; + +router + .post("/operations", handlers.operationsPost) + .body(schemas.operation) + .summary("Add an operation to be applied after consensus") + .description("Add an operation be applied after consensus.") + .response(schemas.operationPostResponse) + .error(400, "Failed to add the operation") + .error(403, "Bad signature") + .error(429, "Too Many Requests"); + +router + .get("/users/:id", handlers.userGet) + .pathParam( + "id", + joi.string().required().description("the brightid of the user") + ) + .summary("Get information about a user") + .description( + "Gets a user's score, verifications, joining date, lists of connections, groups and eligible groups." + ) + .response(schemas.userGetResponse) + .error(404, "User not found"); + +router + .get("/users/:id/verifications", handlers.userVerificationsGet) + .pathParam( + "id", + joi.string().required().description("the brightid of the user") + ) + .summary("Get verifications of a user") + .description("Gets list of user's verification objects with their properties") + .response(schemas.userVerificationsGetResponse); + +router + .get("/users/:id/profile/:requestor", handlers.userProfileGet) + .pathParam( + "id", + joi + .string() + .required() + .description("the brightid of the user that info requested about") + ) + .pathParam( + "requestor", + joi + .string() + .required() + .description("the brightid of the user that requested info") + ) + .summary("Get profile information of a user") + .response(schemas.userProfileGetResponse) + .error(404, "User not found"); + +router + .get("/users/:id/connections/:direction", handlers.userConnectionsGet) + .pathParam( + "id", + joi.string().required().description("the brightid of the user") + ) + .pathParam( + "direction", + joi + .string() + .required() + .valid("inbound", "outbound") + .description("the direction of the connection") + ) + .summary("Get inbound or outbound connections of a user") + .description("Gets list of user's connections with levels and timestamps") + .response(schemas.userConnectionsGetResponse); + +router + .get("/operations/:hash", handlers.operationGet) + .pathParam( + "hash", + joi.string().required().description("sha256 hash of the operation message") + ) + .summary("Get state and result of an operation") + .response(schemas.operationGetResponse) + .error(404, "Operation not found"); + +router + .get("/verifications/:app/:contextId", handlers.verificationGet) + .pathParam( + "app", + joi.string().required().description("the app that user is verified for") + ) + .pathParam( + "contextId", + joi + .string() + .required() + .description("the contextId of user within the context") + ) + .queryParam( + "verification", + joi.string().description("the verification expression") + ) + .queryParam( + "signed", + joi + .string() + .description( + "the value will be eth or nacl to indicate the type of signature returned" + ) + ) + .queryParam( + "timestamp", + joi + .string() + .description( + 'request a timestamp of the specified format to be added to the response. Accepted values: "seconds", "milliseconds"' + ) + ) + .summary("Gets a signed verification") + .description( + "Gets a signed verification for the user that is signed by the node" + ) + .response(schemas.verificationGetResponse) + .error(403, "user is not sponsored") + .error(404, "context, contextId or verification not found"); + +router + .get("/verifications/:app", handlers.allVerificationsGet) + .pathParam( + "app", + joi + .string() + .required() + .description("the app for which the user is verified") + ) + .summary("Gets list of all of contextIds verifed for an app") + .description( + "Gets list of all of contextIds in the context that are sponsored and verified for using an app" + ) + .response(schemas.allVerificationsGetResponse) + .error(404, "context not found"); + +// this route is deprecated and will be removed on v6 +router + .get("/ip", handlers.ipGet) + .summary("Get this server's IPv4 address") + .response(schemas.ipGetResponse); + +router + .get("/apps/:app", handlers.appGet) + .pathParam( + "app", + joi.string().required().description("Unique name of the app") + ) + .summary("Get information about an app") + .response(schemas.appGetResponse) + .error(404, "app not found"); + +router + .get("/apps", handlers.allAppsGet) + .summary("Get all apps") + .response(schemas.allAppsGetResponse); + +router + .get("/state", handlers.stateGet) + .summary("Get state of this node") + .response(schemas.stateGetResponse); + +router + .put("/testblocks/:app/:action/:contextId", handlers.testblocksPut) + .pathParam("app", joi.string().required().description("The key of app")) + .pathParam( + "action", + joi + .string() + .valid("sponsorship", "link", "verification") + .required() + .description("The action name") + ) + .pathParam( + "contextId", + joi + .string() + .required() + .description("the contextId of user within the context") + ) + .queryParam( + "testingKey", + joi.string().required().description("the secret key for testing the app") + ) + .summary("Block user's verification for testing") + .description( + "Updating state of contextId to be considered as unsponsored, unlinked or unverified temporarily for testing" + ) + .response(null); + +router + .delete("/testblocks/:app/:action/:contextId", handlers.testblocksDelete) + .pathParam( + "app", + joi.string().required().description("Unique name of the app") + ) + .pathParam( + "action", + joi + .string() + .required() + .valid("sponsorship", "link", "verification") + .description("The action name") + ) + .pathParam( + "contextId", + joi + .string() + .required() + .description("the contextId of user within the context") + ) + .queryParam( + "testingKey", + joi.string().description("the testing private key of the app") + ) + .summary("Remove blocking state applied on user's verification for testing") + .description( + "Remove limitations applied to a contextId to be considered as unsponsored, unlinked or unverified temporarily for testing" + ) + .response(null); + +router + .get("/contexts/:context/dump", handlers.contextDumpGet) + .pathParam("context", joi.string().required().description("the context key")) + .queryParam( + "passcode", + joi + .string() + .required() + .description( + "the one time passcode that authorize access to this endpoint once" + ) + ) + .summary("Get dump of a context") + .description("Get all required info to transfer a context to a new node") + .response(schemas.contextDumpGetResponse) + .error(404, "context not found") + .error(403, "passcode not set") + .error(403, "incorrect passcode"); + +router + .get("/groups/:id", handlers.groupGet) + .pathParam("id", joi.string().required().description("the id of the group")) + .summary("Get information about a group") + .description( + "Gets a group's admins, founders, info, isNew, region, seed, type, url, timestamp, members, invited and eligible members." + ) + .response(schemas.groupGetResponse) + .error(404, "Group not found"); + +router + .get("/sponsorships/:contextId", handlers.sponsorshipGet) + .pathParam( + "contextId", + joi + .string() + .required() + .description("the contextId of user within the context") + ) + .summary("Gets sponsorship information of a contextId") + .response(schemas.sponsorshipGetResponse) + .error(403, "user is not sponsored"); + +module.context.use(function (req, res, next) { + try { + next(); + } catch (e) { + if (e.cause && e.cause.isJoi) { + e.code = 400; + if ( + req._raw.url.includes("operations") && + e.cause.details && + e.cause.details.length > 0 + ) { + let msg1 = ""; + const msg2 = "invalid operation name"; + e.cause.details.forEach((d) => { + if (!d.message.includes('"name" must be one of')) { + msg1 += `${d.message}, `; + } + }); + e.message = msg1 || msg2; + } + } + if (!(e instanceof errors.NotFoundError)) { + console.group("Error returned"); + console.log("url:", req._raw.requestType, req._raw.url); + console.log("error:", e); + console.log("body:", req.body); + console.groupEnd(); + } + let options = undefined; + if (e instanceof ArangoError) { + options = { extra: { arangoErrorNum: e.errorNum } }; + e.errorNum = errors.ARANGO_ERROR; + } + res.throw(e.code || 500, e, options); + } +}); + +module.exports = { + handlers, +}; diff --git a/web_services/foxx/apply5/initdb.js b/web_services/foxx/apply5/initdb.js new file mode 100755 index 00000000..373b7ab6 --- /dev/null +++ b/web_services/foxx/apply5/initdb.js @@ -0,0 +1,472 @@ +const arango = require("@arangodb").db; +const db = require("./db"); +const { query } = require("@arangodb"); + +const collections = { + connections: "edge", + connectionsHistory: "edge", + groups: "document", + usersInGroups: "edge", + users: "document", + contexts: "document", + apps: "document", + sponsorships: "edge", + operations: "document", + operationsHashes: "document", + invitations: "edge", + variables: "document", + verifications: "document", + testblocks: "document", + operationCounters: "document", +}; + +// deprecated collections should be added to this array after releasing +// second update to allow 2 last released versions work together +const deprecated = ["removed", "newGroups", "usersInNewGroups"]; + +const indexes = [ + { collection: "verifications", fields: ["user"], type: "persistent" }, + { collection: "verifications", fields: ["name"], type: "persistent" }, + { collection: "verifications", fields: ["block"], type: "persistent" }, + { + collection: "sponsorships", + fields: ["expireDate"], + type: "ttl", + expireAfter: 0, + }, + { collection: "sponsorships", fields: ["appId"], type: "persistent" }, + { collection: "connections", fields: ["level"], type: "persistent" }, + { + collection: "connectionsHistory", + fields: ["timestamp"], + type: "persistent", + }, + { collection: "groups", fields: ["seed"], type: "persistent" }, + { collection: "operations", fields: ["state"], type: "persistent" }, + { + collection: "operationCounters", + fields: ["expireDate"], + type: "ttl", + expireAfter: 0, + }, +]; + +const variables = [ + { _key: "LAST_DB_UPGRADE", value: -1 }, + { _key: "VERIFICATIONS_HASHES", hashes: "{}" }, + { _key: "VERIFICATION_BLOCK", value: 0 }, + // 2021/02/09 as starting point for applying new seed connected + { _key: "PREV_SNAPSHOT_TIME", value: 1612900000 }, +]; + +const variablesColl = arango._collection("variables"); + +function createCollections() { + console.log("creating collections if they do not exist ..."); + for (let collection in collections) { + const coll = arango._collection(collection); + if (coll) { + console.log(`${collection} exists`); + } else { + const type = collections[collection]; + arango._create(collection, {}, type); + console.log(`${collection} created with type ${type}`); + } + } +} + +function createIndexes() { + console.log("creating indexes ..."); + for (let index of indexes) { + const coll = arango._collection(index.collection); + console.log(`${index.fields} indexed in ${index.collection} collection`); + delete index.collection; + coll.ensureIndex(index); + } +} + +function removeDeprecatedCollections() { + console.log("removing deprecated collections"); + for (let collection of deprecated) { + const coll = arango._collection(collection); + if (coll) { + arango._drop(collection); + console.log(`${collection} dropped`); + } else { + console.log(`${collection} dropped before`); + } + } +} + +function initializeVariables() { + console.log("initialize variables ..."); + for (let variable of variables) { + if (!variablesColl.exists(variable._key)) { + variablesColl.insert(variable); + } + } +} + +function v5() { + const contextsColl = arango._collection("contexts"); + const appsColl = arango._collection("apps"); + const contexts = contextsColl.all().toArray(); + for (let context of contexts) { + appsColl.insert({ + _key: context["_key"], + name: context["_key"], + context: context["_key"], + url: context["appUrl"], + logo: context["appLogo"], + totalSponsorships: context["totalSponsorships"], + sponsorPublicKey: context["sponsorPublicKey"], + sponsorPrivateKey: context["sponsorPrivateKey"], + sponsorEventContract: context["contractAddress"], + wsProvider: + context["wsProvider"] || + "wss://mainnet.infura.io/ws/v3/36e48f8228ad42a297049cabc1101324", + }); + contextsColl.replace(context, { + collection: context["collection"], + verification: context["verification"], + linkAESKey: context["linkAESKey"], + idsAsHex: context["idsAsHex"], + ethName: context["ethName"], + }); + } + const sponsorshipsColl = arango._collection("sponsorships"); + const sponsorships = sponsorshipsColl.all().toArray(); + for (let sponsorship of sponsorships) { + sponsorshipsColl.update(sponsorship, { + _to: sponsorship["_to"].replace("contexts/", "apps/"), + }); + } +} + +function v5_3() { + const usersColl = arango._collection("users"); + const connectionsColl = arango._collection("connections"); + const timestamp = Date.now(); + + connectionsColl + .all() + .toArray() + .forEach((conn) => { + const key1 = conn._from.replace("users/", ""); + const key2 = conn._to.replace("users/", ""); + if (conn.timestamp < 1597276800000) { + // 08/13/2020 12:00am (UTC) + db.connect({ + id1: key1, + id2: key2, + level: "already known", + timestamp: conn.timestamp, + }); + db.connect({ + id1: key2, + id2: key1, + level: "already known", + timestamp: conn.timestamp, + }); + } else { + db.connect({ + id1: key1, + id2: key2, + level: "just met", + timestamp: conn.timestamp, + }); + db.connect({ + id1: key2, + id2: key1, + level: "just met", + timestamp: conn.timestamp, + }); + } + }); + usersColl + .all() + .toArray() + .forEach((user) => { + if (user.trusted) { + for (let conn of user.trusted) { + db.connect({ + id1: user._key, + id2: conn, + level: "recovery", + timestamp: user.updateTime, + }); + } + } + if (user.flaggers) { + for (let flagger in user.flaggers) { + db.connect({ + id1: flagger, + id2: user._key, + level: "reported", + reportReason: user.flaggers[flagger], + timestamp, + }); + } + } + }); +} + +function v5_5() { + console.log("add current connections to the connectionsHistory"); + const connectionsColl = arango._collection("connections"); + const connectionsHistoryColl = arango._collection("connectionsHistory"); + connectionsColl + .all() + .toArray() + .forEach((conn) => { + connectionsHistoryColl.insert({ + _from: conn["_from"], + _to: conn["_to"], + level: conn["level"], + reportReason: conn["reportReason"], + replacedWith: conn["replacedWith"], + requestProof: conn["requestProof"], + timestamp: conn["timestamp"], + }); + }); + + console.log("removing 'score' attribute form groups collection"); + const groupsColl = arango._collection("groups"); + query` + FOR doc IN ${groupsColl} + REPLACE UNSET(doc, 'score') IN ${groupsColl}`; + + console.log( + "removing 'score', 'verifications', 'flaggers', 'trusted' attributes form users collection" + ); + const usersColl = arango._collection("users"); + query` + FOR doc IN ${usersColl} + REPLACE doc WITH UNSET(doc, 'score', 'verifications', 'flaggers', 'trusted') IN ${usersColl}`; + + console.log("removing 'verification' attribute form contexts collection"); + const contextsColl = arango._collection("contexts"); + query` + FOR doc IN ${contextsColl} + REPLACE doc WITH UNSET(doc, 'verification') IN ${contextsColl}`; + + console.log( + "removing 'Yekta_0', 'Yekta_1', 'Yekta_2', 'Yekta_3', 'Yekta_4', 'Yekta_5' documents form verifications collection" + ); + const verificationsColl = arango._collection("verifications"); + for (let verificationName of [ + "Yekta_0", + "Yekta_1", + "Yekta_2", + "Yekta_3", + "Yekta_4", + "Yekta_5", + ]) { + verificationsColl.removeByExample({ name: verificationName }); + } +} + +function v5_6() { + console.log("removing 'verification' attribute form contexts collection"); + const contextsColl = arango._collection("contexts"); + query` + FOR doc IN ${contextsColl} + REPLACE doc WITH UNSET(doc, 'verification') IN ${contextsColl}`; + + console.log("removing 'ethName' attribute form context collection"); + query` + FOR doc IN ${contextsColl} + REPLACE UNSET(doc, 'ethName') IN ${contextsColl}`; +} + +function v5_6_1() { + console.log("use _key instead of _id in admins and founders of groups"); + const groupsColl = arango._collection("groups"); + const groups = groupsColl.all().toArray(); + for (let group of groups) { + groupsColl.update(group, { + founders: group.founders.map((f) => f.replace("users/", "")), + admins: (group.admins || group.founders).map((a) => + a.replace("users/", "") + ), + }); + } +} + +function v5_7() { + console.log("change 'signingKey' to 'signingKeys' attribute in the users"); + query` + FOR u IN users + UPDATE { _key: u._key, signingKeys: [u.signingKey] } IN users`; + + query` + FOR u IN users + REPLACE UNSET(u, 'signingKey') IN users`; +} + +function v5_8() { + console.log( + "removing 'Yekta_0', 'Yekta_1', 'Yekta_2', 'Yekta_3', 'Yekta_4', 'Yekta_5' documents form verifications collection" + ); + const verificationsColl = arango._collection("verifications"); + for (let verificationName of [ + "Yekta_0", + "Yekta_1", + "Yekta_2", + "Yekta_3", + "Yekta_4", + "Yekta_5", + ]) { + verificationsColl.removeByExample({ name: verificationName }); + } + + console.log("adding block to verifications"); + block = variablesColl.document("VERIFICATION_BLOCK").value; + query` + FOR v in verifications + UPDATE { _key: v._key, block: ${block} } IN verifications`; + + console.log("adding initTimestamp to connections"); + query` + FOR c in connections + UPDATE { _key: c._key, initTimestamp: ( + FOR ch in connectionsHistory + FILTER ch._from == c._from AND ch._to == c._to + SORT ch.timestamp + LIMIT 1 + RETURN ch.timestamp + )[0] } IN connections`; +} + +function v5_9() { + console.log( + "reducing 'recovery' level to 'just met' for connections that another side is not 'already known' or 'recovery'" + ); + const connectionsColl = arango._collection("connections"); + const connectionsHistoryColl = arango._collection("connectionsHistory"); + const now = Date.now(); + connectionsColl + .byExample({ + level: "recovery", + }) + .toArray() + .forEach((ft) => { + const tf = connectionsColl.firstExample({ + _from: ft._to, + _to: ft._from, + }); + if (!tf || !["already known", "recovery"].includes(tf.level)) { + db.connect({ + id1: ft._from.replace("users/", ""), + id2: ft._to.replace("users/", ""), + level: "just met", + timestamp: now, + }); + } + }); + + console.log("removing invalid contextIds form contexts' collection"); + const re = new RegExp(/^0[xX][A-Fa-f0-9]+$/); + const contextsColl = arango._collection("contexts"); + contextsColl + .all() + .toArray() + .map((context) => { + const contextColl = arango._collection(context.collection); + if (!contextColl) { + return; + } + const docs = contextColl.all().toArray(); + for (let doc of docs) { + if (!doc.contextId || (context.idsAsHex && !re.test(doc.contextId))) { + contextColl.removeByExample(doc); + } + } + }); +} + +function v5_9_1() { + let hashes = variablesColl.document("VERIFICATIONS_HASHES").hashes; + new_hashes = {}; + for (let item of hashes) { + new_hashes[item["block"]] = item; + delete item["block"]; + } + variablesColl.update("VERIFICATIONS_HASHES", { hashes: new_hashes }); +} + +function v5_9_2() { + let hashes = variablesColl.document("VERIFICATIONS_HASHES").hashes; + variablesColl.update("VERIFICATIONS_HASHES", { + hashes: JSON.stringify(hashes), + }); +} + +function v5_9_8() { + console.log("removing invalid contextIds form contexts' collection"); + const re = new RegExp(/^0[xX][A-Fa-f0-9]+$/); + + const contextsColl = arango._collection("contexts"); + contextsColl + .all() + .toArray() + .map((context) => { + console.log(`Context: ${context._key}`); + const contextColl = arango._collection(context.collection); + if (!contextColl) { + return; + } + const docs = contextColl.all().toArray(); + for (let doc of docs) { + if ( + !doc.contextId || + (context.idsAsHex && !db.isEthereumAddress(doc.contextId)) + ) { + console.log(`Remove invalid contextId: ${doc.contextId}`); + contextColl.removeByExample(doc); + } else if ( + context.idsAsHex && + db.isEthereumAddress(doc.contextId) && + doc.contextId != doc.contextId.toLowerCase() + ) { + console.log(`Update checksum contextId: ${doc.contextId}`); + contextColl.update(doc, { contextId: doc.contextId.toLowerCase() }); + } + } + }); +} + +const upgrades = [ + "v5", + "v5_3", + "v5_5", + "v5_6", + "v5_6_1", + "v5_7", + "v5_8", + "v5_9", + "v5_9_1", + "v5_9_2", + "v5_9_8", +]; + +function initdb() { + createCollections(); + createIndexes(); + removeDeprecatedCollections(); + initializeVariables(); + let index; + if (variablesColl.exists("LAST_DB_UPGRADE")) { + upgrade = variablesColl.document("LAST_DB_UPGRADE").value; + index = upgrades.indexOf(upgrade) + 1; + } else { + index = 0; + } + while (upgrades[index]) { + eval(upgrades[index])(); + variablesColl.update("LAST_DB_UPGRADE", { value: upgrades[index] }); + index += 1; + } +} + +initdb(); diff --git a/web_services/foxx/apply5/manifest.json b/web_services/foxx/apply5/manifest.json new file mode 100644 index 00000000..cbc2a23f --- /dev/null +++ b/web_services/foxx/apply5/manifest.json @@ -0,0 +1,9 @@ +{ + "main": "apply.js", + "name": "apply", + "description": "Allows BrightID consensus module to apply operations to the database.", + "version": "5.18.0", + "scripts": { + "setup": "initdb.js" + } +} diff --git a/web_services/foxx/apply5/manifest_apply.json b/web_services/foxx/apply5/manifest_apply.json new file mode 100644 index 00000000..cbc2a23f --- /dev/null +++ b/web_services/foxx/apply5/manifest_apply.json @@ -0,0 +1,9 @@ +{ + "main": "apply.js", + "name": "apply", + "description": "Allows BrightID consensus module to apply operations to the database.", + "version": "5.18.0", + "scripts": { + "setup": "initdb.js" + } +} diff --git a/web_services/foxx/apply5/operations.js b/web_services/foxx/apply5/operations.js new file mode 100755 index 00000000..4fc56a9b --- /dev/null +++ b/web_services/foxx/apply5/operations.js @@ -0,0 +1,331 @@ +const stringify = require("fast-json-stable-stringify"); +const db = require("./db"); +const { db: arango, query } = require("@arangodb"); +var CryptoJS = require("crypto-js"); +const nacl = require("tweetnacl"); +const { + strToUint8Array, + b64ToUint8Array, + urlSafeB64ToB64, + hash, +} = require("./encoding"); +const errors = require("./errors"); + +const usersColl = arango._collection("users"); +const operationCountersColl = arango._collection("operationCounters"); +const sponsorshipsColl = arango._collection("sponsorships"); + +const TIME_FUDGE = 60 * 60 * 1000; // timestamp can be this far in the future (milliseconds) to accommodate client/server clock differences + +const verifyUserSig = function (message, id, sig) { + const user = db.loadUser(id); + // When "Add Connection" is called on a user that is not created yet + // signingKey can be calculated from user's brightid + let signingKeys = user ? user.signingKeys : [urlSafeB64ToB64(id)]; + for (signingKey of signingKeys) { + if ( + nacl.sign.detached.verify( + strToUint8Array(message), + b64ToUint8Array(sig), + b64ToUint8Array(signingKey) + ) + ) { + return signingKey; + } + } + throw new errors.InvalidSignatureError(); +}; + +const verifyAppSig = function (message, app, sig) { + app = db.getApp(app); + if ( + !nacl.sign.detached.verify( + strToUint8Array(message), + b64ToUint8Array(sig), + b64ToUint8Array(app.sponsorPublicKey) + ) + ) { + throw new errors.InvalidSignatureError(); + } +}; + +const senderAttrs = { + Connect: ["id1"], + "Add Connection": ["id1", "id2"], + "Remove Connection": ["id1"], + "Add Group": ["id1"], + "Remove Group": ["id"], + "Add Membership": ["id"], + "Remove Membership": ["id"], + "Set Trusted Connections": ["id"], + "Set Signing Key": ["id"], + Sponsor: ["app","id"], //! deprecated app is not used anymore + "Spend Sponsorship": ["app"], //! deprecated + "Link ContextId": ["id"], + Invite: ["inviter"], + Dismiss: ["dismisser"], + "Add Admin": ["id"], + "Add Signing Key": ["id"], + "Remove Signing Key": ["id"], + "Remove All Signing Keys": ["id"], + "Update Group": ["id"], +}; + +function checkLimits(op, timeWindow, limit) { + let expireDate; + const now = Date.now(); + const senders = senderAttrs[op.name].map((attr) => op[attr]); + for (let sender of senders) { + // these condition structure is applying: + // 1) a bucket for a verified user + // 2) a bucket for children of a verified user + // 3) a bucket for all non-verified users without parent + // 4) a bucket for an app + // where parent is the first verified user that make connection with the user + + //! deprecated we don't accept spend sponsorship anymore + if (op["name"] == "Spend Sponsorship") { + const app = db.getApp(op.app); + const contextId = app.idsAsHex ? op.contextId.toLowerCase() : op.contextId; + const sponsorship = sponsorshipsColl.firstExample({ + appId: contextId, + }); + if (!sponsorship) { + sender = "shared_apps"; + } else if (sponsorship.spendRequested) { + throw new errors.SpendRequestedBeforeError(); + } else if (!sponsorship.appHasAuthorized) { + sender = "shared_apps"; + } + } + //! deprecated we don't accept sponsor anymore + if (!["Sponsor", "Spend Sponsorship"].includes(op["name"])) { + if (!usersColl.exists(sender)) { + // this happens when operation is "Add Connection" and one/both sides don't exist + sender = "shared"; + } else { + const user = usersColl.document(sender); + const verifications = db + .userVerifications(user._key) + .map((v) => v.name); + verified = verifications && verifications.includes("BrightID"); + if (!verified && user.parent) { + // this happens when user is not verified but has a verified connection + sender = `shared_${user.parent}`; + } else if (!verified && !user.parent) { + // this happens when user is not verified and does not have a verified connection + sender = "shared"; + } + } + } + const c = operationCountersColl.firstExample({ _key: sender }); + let counter = c ? c.counter : 0; + expireDate = c ? c.expireDate : Math.ceil(now / 1000 + timeWindow / 1000); + counter += 1; + query` + UPSERT { _key: ${sender} } + INSERT { + _key: ${sender}, + counter: ${counter}, + expireDate: ${expireDate}, + } + UPDATE { counter: ${counter} } + IN operationCounters + `; + + if (counter <= limit) { + // if operation has multiple senders, this check will be passed + // even if one of the senders did not reach limit yet + return; + } + } + + throw new errors.TooManyOperationsError( + senders, + expireDate * 1000 - now, + timeWindow, + limit + ); +} + +const signerAndSigs = { + "Remove Connection": ["id1", "sig1"], + "Add Group": ["id1", "sig1"], + "Remove Group": ["id", "sig"], + "Add Membership": ["id", "sig"], + "Remove Membership": ["id", "sig"], + "Set Trusted Connections": ["id", "sig"], + "Link ContextId": ["id", "sig"], + Invite: ["inviter", "sig"], + Dismiss: ["dismisser", "sig"], + "Add Admin": ["id", "sig"], + "Update Group": ["id", "sig"], + "Add Signing Key": ["id", "sig"], + "Remove Signing Key": ["id", "sig"], + "Remove All Signing Keys": ["id", "sig"], +}; + +function verify(op) { + if (op.v != 5) { + throw new errors.InvalidOperationVersionError(op.v); + } + if (op.timestamp > Date.now() + TIME_FUDGE) { + throw new errors.InvalidOperationTimestampError(op.timestamp); + } + + let message = getMessage(op); + if(op.name == "Sponsor" && op.id){ + verifyUserSig(message, op.id, op.sig); + } + //! deprecated + else if (op.name == "Sponsor" && op.contextId) { + verifyAppSig(message, op.app, op.sig); + // prevent apps from sending duplicate sponsor requests + if (db.sponsorRequestedRecently(op)) { + throw new errors.SponsorRequestedRecently(); + } else if (db.isSponsoredByContextId(op)) { + throw new errors.SponsoredBeforeError(); + } + } else if (op.name == "Spend Sponsorship") { + // there is no sig on this operation + //! deprecated we don't accept spend sponsorship anymore + return; + } else if (op.name == "Set Signing Key") { + const recoveryConnections = db.getRecoveryConnections(op.id); + if ( + op.id1 == op.id2 || + !recoveryConnections.includes(op.id1) || + !recoveryConnections.includes(op.id2) + ) { + throw new errors.NotRecoveryConnectionsError(); + } + verifyUserSig(message, op.id1, op.sig1); + verifyUserSig(message, op.id2, op.sig2); + } else if (op.name == "Add Connection") { + verifyUserSig(message, op.id1, op.sig1); + verifyUserSig(message, op.id2, op.sig2); + } else if (op.name == "Connect") { + verifyUserSig(message, op.id1, op.sig1); + if (op.requestProof) { + verifyUserSig(op.id2 + "|" + op.timestamp, op.id2, op.requestProof); + } + } else { + const [signerAttr, sigAttr] = signerAndSigs[op.name]; + const signer = op[signerAttr]; + const sig = op[sigAttr]; + verifyUserSig(message, signer, sig); + } + if (hash(message) != op.hash) { + throw new errors.InvalidOperationHashError(); + } +} + +function apply(op) { + if (op["name"] == "Remove All Signing Keys") { + // verifyUserSig returns the key that used to sign the op + // removeAllSigningKeys remove all keys except this one + const signingKey = verifyUserSig(getMessage(op), op.id, op.sig); + op.timestamp = op.blockTime; + return db.removeAllSigningKeys(op.id, signingKey, op.timestamp); + } + + // set the block time instead of user timestamp + op.timestamp = op.blockTime; + if (op["name"] == "Connect") { + return db.connect(op); + } else if (op["name"] == "Add Connection") { + // this operation is deprecated and will be removed on v6 + // use "Connect" instead + return db.addConnection(op.id1, op.id2, op.timestamp); + } else if (op["name"] == "Remove Connection") { + // this operation is deprecated and will be removed on v6 + // use "Connect" instead + return db.removeConnection(op.id1, op.id2, op.reason, op.timestamp); + } else if (op["name"] == "Add Group") { + return db.createGroup( + op.group, + op.id1, + op.id2, + op.inviteData2, + op.id3, + op.inviteData3, + op.url, + op.type, + op.timestamp + ); + } else if (op["name"] == "Remove Group") { + return db.deleteGroup(op.group, op.id, op.timestamp); + } else if (op["name"] == "Add Membership") { + return db.addMembership(op.group, op.id, op.timestamp); + } else if (op["name"] == "Remove Membership") { + return db.deleteMembership(op.group, op.id, op.timestamp); + } else if (op["name"] == "Set Trusted Connections") { + // this operation is deprecated and will be removed on v6 + // use "Connect" instead + return db.setRecoveryConnections(op.trusted, op.id, op.timestamp); + } else if (op["name"] == "Set Signing Key") { + return db.setSigningKey(op.signingKey, op.id, op.timestamp); + //!deprecated we don't accept sponsor anymore + } else if (["Sponsor", "Spend Sponsorship"].includes(op["name"])) { + return db.sponsor(op); + } else if (op["name"] == "Link ContextId") { + return db.linkContextId(op.id, op.context, op.contextId, op.timestamp); + } else if (op["name"] == "Invite") { + return db.invite(op.inviter, op.invitee, op.group, op.data, op.timestamp); + } else if (op["name"] == "Dismiss") { + return db.dismiss(op.dismisser, op.dismissee, op.group, op.timestamp); + } else if (op["name"] == "Add Admin") { + return db.addAdmin(op.id, op.admin, op.group, op.timestamp); + } else if (op["name"] == "Add Signing Key") { + return db.addSigningKey(op.id, op.signingKey, op.timestamp); + } else if (op["name"] == "Remove Signing Key") { + return db.removeSigningKey(op.id, op.signingKey, op.timestamp); + } else if (op["name"] == "Update Group") { + return db.updateGroup(op.id, op.group, op.url, op.timestamp); + } else { + throw new errors.InvalidOperationNameError(op["name"]); + } +} + +function encrypt(op) { + const { linkAESKey } = db.getContext(op.context); + const jsonStr = stringify({ id: op.id, contextId: op.contextId }); + op.encrypted = CryptoJS.AES.encrypt(jsonStr, linkAESKey).toString(); + delete op.id; + delete op.contextId; +} + +function getMessage(op) { + const signedOp = {}; + for (let k in op) { + if (["sig", "sig1", "sig2", "hash", "blockTime"].includes(k)) { + continue; + } else if (op.name == "Set Signing Key" && ["id1", "id2"].includes(k)) { + continue; + } + signedOp[k] = op[k]; + } + return stringify(signedOp); +} + +function decrypt(op) { + const { linkAESKey } = db.getContext(op.context); + const decrypted = CryptoJS.AES.decrypt(op.encrypted, linkAESKey).toString( + CryptoJS.enc.Utf8 + ); + const json = JSON.parse(decrypted); + delete op.encrypted; + op.id = json.id; + op.contextId = json.contextId; + op.hash = hash(getMessage(op)); +} + +module.exports = { + verify, + apply, + encrypt, + decrypt, + verifyUserSig, + checkLimits, + getMessage, +}; diff --git a/web_services/foxx/apply5/package-lock.json b/web_services/foxx/apply5/package-lock.json new file mode 100644 index 00000000..201c893f --- /dev/null +++ b/web_services/foxx/apply5/package-lock.json @@ -0,0 +1,271 @@ +{ + "name": "brightid-foxx", + "version": "5.18.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "brightid-foxx", + "version": "5.18.0", + "dependencies": { + "base64-js": "^1.3.0", + "crypto-js": "^3.1.9-1", + "expr-eval": "^2.0.2", + "fast-json-stable-stringify": "^2.1.0", + "keccak": "^3.0.0", + "lodash": "^4.17.11", + "secp256k1": "^4.0.0", + "tweetnacl": "^1.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz", + "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==" + }, + "node_modules/bn.js": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==" + }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=" + }, + "node_modules/crypto-js": { + "version": "3.1.9-1", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.1.9-1.tgz", + "integrity": "sha1-/aGedh/Ad+Af+/3G6f38WeiAbNg=" + }, + "node_modules/elliptic": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz", + "integrity": "sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw==", + "dependencies": { + "bn.js": "^4.4.0", + "brorand": "^1.0.1", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.0" + } + }, + "node_modules/expr-eval": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/expr-eval/-/expr-eval-2.0.2.tgz", + "integrity": "sha512-4EMSHGOPSwAfBiibw3ndnP0AvjDWLsMvGOvWEZ2F96IGk0bIVdjQisOHxReSkE13mHcfbuCiXw+G4y0zv6N8Eg==" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/keccak": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/keccak/-/keccak-3.0.0.tgz", + "integrity": "sha512-/4h4FIfFEpTEuySXi/nVFM5rqSKPnnhI7cL4K3MFSwoI3VyM7AhPSq3SsysARtnEBEeIKMBUWD8cTh9nHE8AkA==", + "hasInstallScript": true, + "dependencies": { + "node-addon-api": "^2.0.0", + "node-gyp-build": "^4.2.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/lodash": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==" + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=" + }, + "node_modules/node-addon-api": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.0.tgz", + "integrity": "sha512-ASCL5U13as7HhOExbT6OlWJJUV/lLzL2voOSP1UVehpRD8FbSrSDjfScK/KwAvVTI5AS6r4VwbOMlIqtvRidnA==" + }, + "node_modules/node-gyp-build": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.2.0.tgz", + "integrity": "sha512-4oiumOLhCDU9Rronz8PZ5S4IvT39H5+JEv/hps9V8s7RSLhsac0TCP78ulnHXOo8X1wdpPiTayGlM1jr4IbnaQ==", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/secp256k1": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-4.0.0.tgz", + "integrity": "sha512-0w0zse+Iku13O58SVE9/DhyCKWNsKb+n/vMqLOGICgSqxWuXZs+eajBf9uVOgk5QfNvTY/mx0QSqYxkcz802dw==", + "hasInstallScript": true, + "dependencies": { + "elliptic": "^6.5.2", + "node-addon-api": "^2.0.0", + "node-gyp-build": "^4.2.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/tweetnacl": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.0.tgz", + "integrity": "sha1-cT2LgY2kIGh0C/aDhtBHnmb8ins=" + } + }, + "dependencies": { + "base64-js": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz", + "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==" + }, + "bn.js": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==" + }, + "brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=" + }, + "crypto-js": { + "version": "3.1.9-1", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.1.9-1.tgz", + "integrity": "sha1-/aGedh/Ad+Af+/3G6f38WeiAbNg=" + }, + "elliptic": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz", + "integrity": "sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw==", + "requires": { + "bn.js": "^4.4.0", + "brorand": "^1.0.1", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.0" + } + }, + "expr-eval": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/expr-eval/-/expr-eval-2.0.2.tgz", + "integrity": "sha512-4EMSHGOPSwAfBiibw3ndnP0AvjDWLsMvGOvWEZ2F96IGk0bIVdjQisOHxReSkE13mHcfbuCiXw+G4y0zv6N8Eg==" + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + "hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "requires": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=", + "requires": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "keccak": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/keccak/-/keccak-3.0.0.tgz", + "integrity": "sha512-/4h4FIfFEpTEuySXi/nVFM5rqSKPnnhI7cL4K3MFSwoI3VyM7AhPSq3SsysARtnEBEeIKMBUWD8cTh9nHE8AkA==", + "requires": { + "node-addon-api": "^2.0.0", + "node-gyp-build": "^4.2.0" + } + }, + "lodash": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==" + }, + "minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, + "minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=" + }, + "node-addon-api": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.0.tgz", + "integrity": "sha512-ASCL5U13as7HhOExbT6OlWJJUV/lLzL2voOSP1UVehpRD8FbSrSDjfScK/KwAvVTI5AS6r4VwbOMlIqtvRidnA==" + }, + "node-gyp-build": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.2.0.tgz", + "integrity": "sha512-4oiumOLhCDU9Rronz8PZ5S4IvT39H5+JEv/hps9V8s7RSLhsac0TCP78ulnHXOo8X1wdpPiTayGlM1jr4IbnaQ==" + }, + "secp256k1": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-4.0.0.tgz", + "integrity": "sha512-0w0zse+Iku13O58SVE9/DhyCKWNsKb+n/vMqLOGICgSqxWuXZs+eajBf9uVOgk5QfNvTY/mx0QSqYxkcz802dw==", + "requires": { + "elliptic": "^6.5.2", + "node-addon-api": "^2.0.0", + "node-gyp-build": "^4.2.0" + } + }, + "tweetnacl": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.0.tgz", + "integrity": "sha1-cT2LgY2kIGh0C/aDhtBHnmb8ins=" + } + } +} diff --git a/web_services/foxx/apply5/package.json b/web_services/foxx/apply5/package.json new file mode 100755 index 00000000..3e53be4f --- /dev/null +++ b/web_services/foxx/apply5/package.json @@ -0,0 +1,15 @@ +{ + "name": "brightid-foxx", + "description": "Foxx service for managing BrightID connections", + "version": "5.18.0", + "dependencies": { + "base64-js": "^1.3.0", + "crypto-js": "^3.1.9-1", + "expr-eval": "^2.0.2", + "fast-json-stable-stringify": "^2.1.0", + "keccak": "^3.0.0", + "lodash": "^4.17.11", + "secp256k1": "^4.0.0", + "tweetnacl": "^1.0.0" + } +} diff --git a/web_services/foxx/apply5/schemas.js b/web_services/foxx/apply5/schemas.js new file mode 100755 index 00000000..8494630b --- /dev/null +++ b/web_services/foxx/apply5/schemas.js @@ -0,0 +1,952 @@ +const joi = require("joi"); + +// lowest-level schemas +var schemas = { + score: joi.number().min(0).max(5).default(0), + timestamp: joi.number().integer(), +}; + +const operations = { + Connect: { + id1: joi + .string() + .required() + .description("brightid of the user making the directed connection"), + id2: joi + .string() + .required() + .description("brightid of the target of the directed connection"), + sig1: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id1" + ), + level: joi + .string() + .valid("reported", "suspicious", "just met", "already known", "recovery") + .required() + .description("level of confidence"), + reportReason: joi + .string() + .valid("spammer", "fake", "duplicate", "deceased", "replaced", "other") + .description( + "for reported level, the reason for reporting the user specificed by id2" + ), + replacedWith: joi + .string() + .description( + "for reported as replaced, the new brightid of the replaced account" + ), + requestProof: joi + .string() + .description( + 'brightid + "|" + timestamp signed by the reported user to prove that he requested the connection' + ), + }, + "Add Connection": { + id1: joi + .string() + .required() + .description("brightid of the first user making the connection"), + id2: joi + .string() + .required() + .description("brightid of the second user making the connection"), + sig1: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id1" + ), + sig2: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id2" + ), + }, + "Remove Connection": { + id1: joi + .string() + .required() + .description("brightid of the user removing the connection"), + id2: joi + .string() + .required() + .description( + "brightid of the second user that the connection with is being removed" + ), + reason: joi + .string() + .valid("fake", "duplicate", "deceased") + .required() + .description( + "the reason for removing connection specificed by the user represented by id1" + ), + sig1: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id1" + ), + }, + "Add Group": { + group: joi.string().required().description("the unique id of the group"), + id1: joi.string().required().description("brightid of the first founder"), + id2: joi.string().required().description("brightid of the second founder"), + id3: joi.string().required().description("brightid of the third founder"), + inviteData2: joi + .string() + .required() + .description( + "the group AES key encrypted for signingKey of the user represented by id2" + ), + inviteData3: joi + .string() + .required() + .description( + "the group AES key encrypted for signingKey of the user represented by id3" + ), + url: joi + .string() + .required() + .description( + "the url that group data (profile image and name) encrypted by group AES key can be fetched from" + ), + type: joi + .string() + .valid("general", "primary") + .required() + .description("type of the group"), + sig1: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the creator of group represented by id1" + ), + }, + "Remove Group": { + id: joi + .string() + .required() + .description("brightid of the group admin who want to remove the group"), + group: joi.string().required().description("the unique id of the group"), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id1" + ), + }, + "Add Membership": { + id: joi + .string() + .required() + .description("brightid of the user wants to join the group"), + group: joi + .string() + .required() + .description( + "the unique id of the group that the user represented by id wants to join" + ), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id" + ), + }, + "Remove Membership": { + id: joi + .string() + .required() + .description("brightid of the user wants to leave the group"), + group: joi + .string() + .required() + .description( + "the unique id of the group that the user represented by id wants to leave" + ), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id" + ), + }, + "Set Trusted Connections": { + id: joi + .string() + .required() + .description("brightid of the user who is setting recovery connections"), + trusted: joi + .array() + .items(joi.string()) + .required() + .description("brightid list of recovery connections"), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id" + ), + }, + // this operation should be renamed to "Social Recovery" in v6 + "Set Signing Key": { + id: joi + .string() + .required() + .description( + "brightid of the user who is resetting signingKeys by social recovery" + ), + signingKey: joi + .string() + .required() + .description( + "the public key of the new key pair that user will use to sign operations with" + ), + id1: joi + .string() + .required() + .description( + "brightid of a recovery connection of the user represented by id" + ), + id2: joi + .string() + .required() + .description( + "brightid of a recovery connection of the user represented by id" + ), + sig1: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the recovery connection represented by id1" + ), + sig2: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the recovery connection represented by id2" + ), + }, + "Link ContextId": { + id: joi + .string() + .description( + "brightid of the user who is linking his/her brightid to a context id" + ), + contextId: joi + .string() + .description( + "the unique id of the user represented by brightid in the specific context" + ), + encrypted: joi + .string() + .description( + "the json representation of `{id: id, contextId: contextId}` encrypted using an AES key shared between all nodes manage linking brightids to contextIds for a specific context. This field is not sent by clients and will be replaced by `id` and `contextId` fields before sending operation to blockchain to keep the relation of brightids to contextIds private." + ), + context: joi + .string() + .required() + .description( + "the context name in which the user represented by brightid is linking context id with his/her brightid" + ), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id" + ), + }, + Sponsor: { + contextId: joi + .string() + .description( + "the contextId for the user that is being sponsored by context" + ), + id: joi + .string() + .description("brightid of the user who is requesting sponsorship"), + app: joi + .string() + .required() + .description("the app key that user is being sponsored by"), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the private key shared between context owners and trusted node operators which enable them to spend sponsorships assigned to the context" + ), + }, + "Spend Sponsorship": { + contextId: joi + .string() + .required() + .description("the contextId that is being sponsored"), + app: joi + .string() + .required() + .description("the app key that user is being sponsored by"), + }, + Invite: { + inviter: joi + .string() + .required() + .description( + "brightid of the user who has admin rights in the group and can invite others to the group" + ), + invitee: joi + .string() + .required() + .description("brightid of the user whom is invited to the group"), + group: joi + .string() + .required() + .description( + "the unique id of the group that invitee is being invited to" + ), + data: joi + .string() + .required() + .description("the group AES key encrypted for signingKey of the invitee"), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the inviter" + ), + }, + Dismiss: { + dismisser: joi + .string() + .required() + .description( + "brightid of the user who has admin rights in the group and can dismiss others from the group" + ), + dismissee: joi + .string() + .required() + .description("brightid of the user whom is dismissed from the group"), + group: joi + .string() + .required() + .description( + "the unique id of the group that dismissee is being dismissed from" + ), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the dismisser" + ), + }, + "Add Admin": { + id: joi + .string() + .required() + .description("brightid of one of the current admins of the group"), + admin: joi + .string() + .required() + .description( + "brightid of the member whom is being granted administratorship of the group" + ), + group: joi.string().required().description("the unique id of the group"), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the admin user represented by id" + ), + }, + "Update Group": { + id: joi + .string() + .required() + .description("brightid of one of the admins of the group"), + group: joi.string().required().description("the unique id of the group"), + url: joi + .string() + .required() + .description( + "the new url that group data (profile image and name) encrypted by group AES key can be fetched from" + ), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id" + ), + }, + "Add Signing Key": { + id: joi + .string() + .required() + .description("brightid of the user who is adding new signingKey"), + signingKey: joi + .string() + .required() + .description( + "the public key of the new key pair that user can sign operations with" + ), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id" + ), + }, + "Remove Signing Key": { + id: joi + .string() + .required() + .description("brightid of the user who is removing the signingKey"), + signingKey: joi + .string() + .required() + .description("the signingKey that is being removed"), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id" + ), + }, + "Remove All Signing Keys": { + id: joi + .string() + .required() + .description( + "brightid of the user who is removing all the signingKeys except the one that used to sign this operation" + ), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id" + ), + }, +}; + +Object.keys(operations).forEach((name) => { + operations[name] = Object.assign( + { + name: joi.string().valid(name).required().description("operation name"), + }, + operations[name], + { + timestamp: joi + .number() + .required() + .description("milliseconds since epoch when the operation created"), + v: joi.number().required().valid(5).description("version of API"), + } + ); +}); + +// extend lower-level schemas with higher-level schemas +schemas = Object.assign( + { + user: joi.object({ + id: joi.string().required().description("the user id"), + // this will be replaced by signingKeys on v6 + signingKey: joi + .string() + .required() + .description("the first signingKey of the user"), + score: schemas.score, + level: joi + .string() + .required() + .description("the confidence level set on this user"), + verifications: joi.array().items(joi.string()), + hasPrimaryGroup: joi + .boolean() + .description("true if user has primary group"), + trusted: joi + .array() + .items(joi.string()) + .description("list of recovery connections of the user"), + flaggers: joi + .object() + .description( + "an object containing ids of flaggers as key and reason as value" + ), + createdAt: schemas.timestamp + .required() + .description("the user creation timestamp"), + }), + connection: joi.object({ + id: joi.string().required().description("the brightid of the connection"), + level: joi.string().required().description("the level of the connection"), + timestamp: schemas.timestamp + .required() + .description("the timestamp of the connection"), + }), + groupBase: joi.object({ + id: joi.string().required().description("unique identifier of the group"), + members: joi + .array() + .items(joi.string()) + .required() + .description("brightids of group members"), + type: joi + .string() + .required() + .description('type of group which is "primary" or "general"'), + founders: joi + .array() + .items(joi.string()) + .required() + .description("brightids of group founders"), + admins: joi + .array() + .items(joi.string()) + .required() + .description("brightids of group admins"), + isNew: joi + .boolean() + .required() + .description( + "true if some of founders did not join the group yet and group is still in founding stage" + ), + // score on group is deprecated and will be removed on v6 + score: schemas.score, + url: joi + .string() + .required() + .description("url of encrypted group data (name and photo)"), + timestamp: schemas.timestamp + .required() + .description("group creation timestamp"), + }), + app: joi.object({ + id: joi.string().required().description("unique app id"), + name: joi.string().required().description("app name"), + context: joi.string().required().description("app context"), + verification: joi + .string() + .required() + .description("verification required for using the app"), + verificationUrl: joi + .string() + .required() + .description("the url to PUT a verification with /:id"), + logo: joi.string().description("app logo (base64 encoded image)"), + url: joi.string().description("the base url for the app"), + assignedSponsorships: joi + .number() + .integer() + .description("number of assigned sponsorships"), + unusedSponsorships: joi + .number() + .integer() + .description("number of unused sponsorships"), + testing: joi + .boolean() + .required() + .description("true if the app is in the testing mode"), + soulbound: joi + .boolean() + .required() + .description("true if the app uses soulbound standard"), + soulboundMessage: joi + .string() + .required() + .description("a static message to be signed at linking time by the context id"), + }), + }, + schemas +); + +schemas = Object.assign( + { + operation: joi + .alternatives() + .try( + Object.keys(operations).map((name) => + name === 'Sponsor' ? + joi.object(operations[name]).label(name).xor('id', 'contextId') : + joi.object(operations[name]).label(name) + ) + ) + .description( + "Send operations to idchain to be applied to BrightID nodes' databases after consensus" + ), + }, + schemas +); + +schemas = Object.assign( + { + group: schemas.groupBase.keys({ + joined: schemas.timestamp + .required() + .description("timestamp when the user joined"), + }), + + invite: schemas.groupBase.keys({ + inviteId: joi + .string() + .required() + .description("unique identifier of invite"), + invited: schemas.timestamp + .required() + .description("timestamp when the user was invited"), + inviter: joi.string().required().description("brightid of inviter"), + data: joi + .string() + .required() + .description( + "encrypted version of the AES key that group name and photo uploaded to `url` encrypted with" + + "invitee should first decrypt this data with his/her signingKey and then fetch data in `url` and decrypt that using the AES key" + ), + }), + }, + schemas +); + +// extend lower-level schemas with higher-level schemas +schemas = Object.assign( + { + operationPostResponse: joi.object({ + data: joi.object({ + hash: joi + .string() + .required() + .description( + "sha256 hash of the operation message used for generating signature" + ), + }), + }), + + userGetResponse: joi.object({ + data: joi.object({ + score: schemas.score, + createdAt: schemas.timestamp.required(), + groups: joi.array().items(schemas.group), + invites: joi.array().items(schemas.invite), + connections: joi.array().items(schemas.user), + verifications: joi.array().items(joi.string()), + isSponsored: joi.boolean(), + trusted: joi.array().items(joi.string()), + flaggers: joi + .object() + .description( + "an object containing ids of flaggers as key and reason as value" + ), + signingKeys: joi + .array() + .items(joi.string()) + .required() + .description( + "list of signing keys that user can sign operations with" + ), + }), + }), + + userConnectionsGetResponse: joi.object({ + data: joi.object({ + connections: joi.array().items(schemas.connection), + }), + }), + + userVerificationsGetResponse: joi.object({ + data: joi.object({ + verifications: joi.array().items(joi.object()), + }), + }), + + userProfileGetResponse: joi.object({ + data: joi.object({ + connectionsNum: joi + .number() + .integer() + .required() + .description( + "number of connections with already known or recovery level" + ), + groupsNum: joi + .number() + .integer() + .required() + .description("number of groups"), + mutualConnections: joi + .array() + .items(joi.string()) + .required() + .description("brightids of mutual connections"), + mutualGroups: joi + .array() + .items(joi.string()) + .required() + .description("ids of mutual groups"), + connectedAt: schemas.timestamp + .required() + .description("timestamp of last connection"), + createdAt: schemas.timestamp + .required() + .description("creation time of user specified by id"), + reports: joi + .array() + .items( + joi.object({ + id: joi.string().required().description("brightid of reporter"), + reportReason: joi + .string() + .required() + .description("the reason of reporting"), + }) + ) + .description("list of reports for the user specified by id"), + verifications: joi + .array() + .items(joi.object()) + .required() + .description( + "list of verification objects user has with properties each verification has" + ), + signingKeys: joi + .array() + .items(joi.string()) + .required() + .description( + "list of signing keys that user can sign operations with" + ), + }), + }), + + operationGetResponse: joi.object({ + data: joi.object({ + state: joi + .string() + .valid("init", "sent", "applied", "failed") + .description("state of operation"), + result: joi + .string() + .description( + "result of operation after being applied. If operation is failed this field contain the reason." + ), + }), + }), + + verificationGetResponse: joi.object({ + data: joi.object({ + unique: joi.string().description("true if user is verified for an app"), + app: joi.string().description("the key of app"), + context: joi.string().description("the context name"), + contextIds: joi + .array() + .items(joi.string()) + .description( + "list of all contextIds this user linked from most recent to oldest including current active contextId as first member" + ), + timestamp: schemas.timestamp.description( + "timestamp of the verification if a timestamp was requested by including a 'timestamp' parameter" + ), + sig: joi + .string() + .description("verification message signed by the node"), + publicKey: joi.string().description("the node's public key"), + }), + }), + + allVerificationsGetResponse: joi.object({ + data: joi.object({ + contextIds: joi + .array() + .items(joi.string()) + .description("an array of contextIds"), + }), + }), + + ipGetResponse: joi.object({ + data: joi.object({ + ip: joi.string().description("IPv4 address in dot-decimal notation."), + }), + }), + + appGetResponse: joi.object({ + data: schemas.app, + }), + + allAppsGetResponse: joi.object({ + data: joi.object({ + apps: joi.array().items(schemas.app), + }), + }), + + stateGetResponse: joi.object({ + data: joi.object({ + lastProcessedBlock: joi + .number() + .integer() + .required() + .description("last block that consensus receiver service processed"), + verificationsBlock: joi + .number() + .integer() + .required() + .description( + "the block that scorer service updated verifications based on operations got applied before that block" + ), + initOp: joi + .number() + .integer() + .required() + .description("number of operations in the init state"), + sentOp: joi + .number() + .integer() + .required() + .description("number of operations in the sent state"), + verificationsHashes: joi + .array() + .items(joi.object()) + .required() + .description("different verifications' hashes for last 2 snapshots"), + ethSigningAddress: joi + .string() + .required() + .description( + "the ethereum address of this node; used for signing verifications" + ), + naclSigningKey: joi + .string() + .required() + .description( + "nacl signing key of this node; used for signing verifications" + ), + consensusSenderAddress: joi + .string() + .required() + .description( + "the ethereum address of consensus sender service of this node; used for sending operations" + ), + version: joi.string().required().description("version of this node"), + }), + }), + + contextDumpGetResponse: joi.object({ + data: joi.object({ + collection: joi + .string() + .required() + .description( + "the collection name used to store contextIds linked under the context" + ), + idsAsHex: joi + .boolean() + .required() + .description("true if contextIds are hex strings"), + linkAESKey: joi + .string() + .required() + .description( + "the AES key used to encrypt links before sending to IDChain and decrypt after receiving them" + ), + contextIds: joi + .array() + .required() + .items(joi.string()) + .description("list of all contextIds linked under the context"), + }), + }), + + groupGetResponse: joi.object({ + data: joi.object({ + members: joi + .array() + .items(joi.string()) + .required() + .description("brightids of members of the group"), + invites: joi + .array() + .items( + joi.object({ + inviter: joi + .string() + .required() + .description("brightid of inviter"), + invitee: joi + .string() + .required() + .description("brightid of invitee"), + id: joi.string().required().description("unique id of invite"), + data: joi + .string() + .required() + .description("AES key of group encrypted for invitee"), + timestamp: joi + .number() + .required() + .description("timestamp of invite"), + }) + ) + .required(), + eligibles: joi + .array() + .items(joi.string()) + .required() + .description( + "brightids of the users that are eligible to join the group" + ), + admins: joi + .array() + .items(joi.string()) + .required() + .description("brightids of admins of the group"), + founders: joi + .array() + .items(joi.string()) + .required() + .description("brightids of founders of the group"), + isNew: joi.boolean().required().description("true if group is new"), + seed: joi.boolean().required().description("true if group is Seed"), + region: joi.string().description("region of the group"), + type: joi.string().required().description("type of the group"), + url: joi.string().required().description("url of the group"), + info: joi + .string() + .description("URL of a documnet that contains info about the group"), + timestamp: joi + .number() + .required() + .description("the group creation timestamp"), + }), + }), + + sponsorshipGetResponse: joi.object({ + data: joi.object({ + app: joi + .string() + .required() + .description("the app key that user is being sponsored by"), + appHasAuthorized: joi + .boolean() + .required() + .description( + "true if the app authorized the node to use sponsorships for this contextId" + ), + spendRequested: joi + .boolean() + .required() + .description( + "true if the client requested to spend sponsorship for this contextId" + ), + timestamp: joi + .number() + .required() + .description("the sponsorship timestamp"), + }), + }), + }, + schemas +); + +module.exports = { + schemas, + operations, +}; diff --git a/web_services/foxx/apply5/tests/connections.js b/web_services/foxx/apply5/tests/connections.js new file mode 100755 index 00000000..25e2dfb7 --- /dev/null +++ b/web_services/foxx/apply5/tests/connections.js @@ -0,0 +1,405 @@ +"use strict"; + +const db = require("../db.js"); +const arango = require("@arangodb").db; +const usersColl = arango._collection("users"); +const connectionsColl = arango._collection("connections"); +const connectionsHistoryColl = arango._collection("connectionsHistory"); + +const chai = require("chai"); +const should = chai.should(); +const timestamp = Date.now(); + +describe("connections", function () { + before(function () { + usersColl.truncate(); + connectionsColl.truncate(); + connectionsHistoryColl.truncate(); + }); + after(function () { + usersColl.truncate(); + connectionsColl.truncate(); + connectionsHistoryColl.truncate(); + }); + it('should be able to use "addConnection" to set "just met" as confidence level', function () { + db.addConnection("a", "b", timestamp); + connectionsColl + .firstExample({ + _from: "users/a", + _to: "users/b", + }) + .level.should.equal("just met"); + connectionsColl + .firstExample({ + _from: "users/b", + _to: "users/a", + }) + .level.should.equal("just met"); + }); + it('should be able to use "connect" to upgrade confidence level to "already known"', function () { + db.connect({ id1: "b", id2: "a", level: "already known", timestamp }); + connectionsColl + .firstExample({ + _from: "users/b", + _to: "users/a", + }) + .level.should.equal("already known"); + }); + it('should be able to use "removeConnection" to report a connection that already knows the reporter', function () { + db.removeConnection("a", "b", "duplicate", timestamp); + const conn = connectionsColl.firstExample({ + _from: "users/a", + _to: "users/b", + }); + conn.level.should.equal("reported"); + conn.reportReason.should.equal("duplicate"); + }); + it('should be able to use "connect" to reset confidence level to "just met"', function () { + db.connect({ id1: "a", id2: "b", level: "just met", timestamp }); + const conn1 = connectionsColl.firstExample({ + _from: "users/a", + _to: "users/b", + }); + conn1.level.should.equal("just met"); + (conn1.reportReason === null).should.equal(true); + }); + it('should be able to use "setRecoveryConnections" to set "recovery" as confidence level', function () { + db.connect({ id1: "b", id2: "a", level: "already known", timestamp }); + db.setRecoveryConnections(["b"], "a", timestamp); + connectionsColl + .firstExample({ + _from: "users/a", + _to: "users/b", + }) + .level.should.equal("recovery"); + }); + it('should not reset "recovery" confidence level to "just met" when calling "addConnection"', function () { + db.addConnection("a", "b", timestamp); + connectionsColl + .firstExample({ + _from: "users/a", + _to: "users/b", + }) + .level.should.equal("recovery"); + connectionsColl + .firstExample({ + _from: "users/b", + _to: "users/a", + }) + .level.should.equal("already known"); + }); + it('should be able to use "connect" to set different confidence levels', function () { + db.connect({ + id1: "a", + id2: "b", + level: "reported", + reportReason: "duplicate", + timestamp, + }); + connectionsColl + .firstExample({ + _from: "users/a", + _to: "users/b", + }) + .level.should.equal("reported"); + db.connect({ id1: "a", id2: "b", level: "just met", timestamp }); + connectionsColl + .firstExample({ + _from: "users/a", + _to: "users/b", + }) + .level.should.equal("just met"); + db.connect({ id1: "a", id2: "b", level: "recovery", timestamp }); + connectionsColl + .firstExample({ + _from: "users/a", + _to: "users/b", + }) + .level.should.equal("recovery"); + db.connect({ id1: "a", id2: "c", level: "just met", timestamp }); + connectionsColl + .firstExample({ + _from: "users/a", + _to: "users/c", + }) + .level.should.equal("just met"); + }); + + it('should be able to use "setSigningKey" to reset "signingKey" with "recovery" connections', function () { + db.connect({ id1: "c", id2: "a", level: "already known", timestamp }); + db.connect({ id1: "a", id2: "c", level: "recovery", timestamp }); + db.setSigningKey("newSigningKey", "a", ["b", "c"], timestamp); + usersColl.document("a").signingKeys.should.deep.equal(["newSigningKey"]); + }); + + it('should be able to get "userConnections"', function () { + db.connect({ + id1: "c", + id2: "a", + level: "reported", + reportReason: "duplicate", + timestamp: 0, + }); + const conns = db.userConnections("b"); + conns.length.should.equal(1); + const a = conns[0]; + a.id.should.equal("a"); + a.level.should.equal("already known"); + }); + + it("should be able to report someone as replaced", function () { + db.connect({ + id1: "c", + id2: "a", + level: "reported", + reportReason: "replaced", + replacedWith: "b", + timestamp, + }); + const conn = connectionsColl.firstExample({ + _from: "users/c", + _to: "users/a", + }); + conn.level.should.equal("reported"); + conn.reportReason.should.equal("replaced"); + conn.replacedWith.should.equal("b"); + }); +}); + +describe("recovery connections", function () { + before(function () { + usersColl.truncate(); + connectionsColl.truncate(); + connectionsHistoryColl.truncate(); + }); + after(function () { + usersColl.truncate(); + connectionsColl.truncate(); + connectionsHistoryColl.truncate(); + }); + + it("users should be able add or remove recovery connections", function () { + db.connect({ id1: "b", id2: "a", level: "already known", timestamp: 1 }); + db.connect({ id1: "a", id2: "b", level: "recovery", timestamp: 1 }); + db.connect({ + id1: "c", + id2: "a", + level: "already known", + timestamp: Date.now() - 30 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "a", + id2: "c", + level: "recovery", + timestamp: Date.now() - 30 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "d", + id2: "a", + level: "already known", + timestamp: Date.now() - 29 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "a", + id2: "d", + level: "recovery", + timestamp: Date.now() - 29 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "e", + id2: "a", + level: "already known", + timestamp: Date.now() - 28 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "a", + id2: "e", + level: "recovery", + timestamp: Date.now() - 28 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "f", + id2: "a", + level: "already known", + timestamp: Date.now() - 22 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "a", + id2: "f", + level: "recovery", + timestamp: Date.now() - 22 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "a", + id2: "b", + level: "reported", + reportReason: "duplicate", + timestamp: Date.now() - 22 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "c", + id2: "b", + level: "already known", + timestamp: Date.now() - 21 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "b", + id2: "c", + level: "recovery", + timestamp: Date.now() - 21 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "c", + id2: "d", + level: "already known", + timestamp: Date.now() - 20 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "d", + id2: "c", + level: "recovery", + timestamp: Date.now(), + }); + db.connect({ + id1: "b", + id2: "c", + level: "already known", + timestamp: Date.now(), + }); + db.connect({ + id1: "a", + id2: "e", + level: "already known", + timestamp: Date.now() - 5 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "a", + id2: "e", + level: "recovery", + timestamp: Date.now() - 2 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "g", + id2: "a", + level: "already known", + timestamp: Date.now() - 10 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "a", + id2: "g", + level: "recovery", + timestamp: Date.now() - 5 * 24 * 60 * 60 * 1000, + }); + + const recoveryConnections = db.getRecoveryConnections("a"); + recoveryConnections.should.deep.equal(["c", "d", "e", "f"]); + }); + + it("should not be able to add a recovery connection without cooling period", function () { + db.connect({ + id1: "a", + id2: "b", + level: "recovery", + timestamp: Date.now(), + }); + let recoveryConnections = db.getRecoveryConnections("a"); + recoveryConnections.should.deep.equal(["c", "d", "e", "f"]); + + db.connect({ + id1: "a", + id2: "b", + level: "already known", + timestamp: Date.now(), + }); + recoveryConnections = db.getRecoveryConnections("a"); + recoveryConnections.should.deep.equal(["c", "d", "e", "f"]); + }); + + it("should not be able to inactive a recovery connection without cooling period", function () { + db.connect({ + id1: "a", + id2: "d", + level: "already known", + timestamp: Date.now(), + }); + let recoveryConnections = db.getRecoveryConnections("a"); + recoveryConnections.should.deep.equal(["c", "d", "e", "f"]); + + db.connect({ + id1: "a", + id2: "d", + level: "recovery", + timestamp: Date.now(), + }); + recoveryConnections = db.getRecoveryConnections("a"); + recoveryConnections.should.deep.equal(["c", "d", "e", "f"]); + }); + + it("remove recovery connection should take one week to take effect to protect against takeover", function () { + db.connect({ + id1: "a", + id2: "c", + level: "reported", + reportReason: "duplicate", + timestamp: Date.now(), + }); + + const recoveryConnections = db.getRecoveryConnections("a"); + recoveryConnections.should.deep.equal(["c", "d", "e", "f"]); + }); + + it("don't allow a recovery connection to be used for recovery if it is too new", function () { + db.connect({ + id1: "h", + id2: "a", + level: "already known", + timestamp: Date.now(), + }); + db.connect({ + id1: "a", + id2: "h", + level: "recovery", + timestamp: Date.now(), + }); + + const recoveryConnections = db.getRecoveryConnections("a"); + recoveryConnections.should.deep.equal(["c", "d", "e", "f"]); + }); + + it("ignore cooling period from recovery connections set in the first day", function () { + connectionsColl.truncate(); + connectionsHistoryColl.truncate(); + const firstConnTime = Date.now() - 4 * 24 * 60 * 60 * 1000; + db.connect({ id1: "b", id2: "a", level: "already known", timestamp: 1 }); + db.connect({ + id1: "a", + id2: "b", + level: "recovery", + timestamp: firstConnTime, + }); + db.connect({ id1: "c", id2: "a", level: "already known", timestamp: 1 }); + db.connect({ + id1: "a", + id2: "c", + level: "recovery", + timestamp: firstConnTime + 5 * 60 * 60 * 1000, + }); + db.connect({ id1: "d", id2: "a", level: "already known", timestamp: 1 }); + db.connect({ + id1: "a", + id2: "d", + level: "recovery", + timestamp: firstConnTime + 22 * 60 * 60 * 1000, + }); + db.connect({ id1: "e", id2: "a", level: "already known", timestamp: 1 }); + db.connect({ + id1: "a", + id2: "e", + level: "recovery", + timestamp: firstConnTime + 30 * 60 * 60 * 1000, + }); + + const recoveryConnections = db.getRecoveryConnections("a", "outbound"); + recoveryConnections.should.deep.equal(["b", "c", "d"]); + }); +}); diff --git a/web_services/foxx/brightid/tests/encoding.js b/web_services/foxx/apply5/tests/encoding.js similarity index 100% rename from web_services/foxx/brightid/tests/encoding.js rename to web_services/foxx/apply5/tests/encoding.js diff --git a/web_services/foxx/apply5/tests/errors.js b/web_services/foxx/apply5/tests/errors.js new file mode 100755 index 00000000..e849fa5d --- /dev/null +++ b/web_services/foxx/apply5/tests/errors.js @@ -0,0 +1,188 @@ +"use strict"; + +const db = require("../db.js"); +const _ = require("lodash"); +const { getMessage } = require("../operations"); +const errors = require("../errors"); +const arango = require("@arangodb").db; +const request = require("@arangodb/request"); +const nacl = require("tweetnacl"); +nacl.setPRNG(function (x, n) { + for (let i = 0; i < n; i++) { + x[i] = Math.floor(Math.random() * 256); + } +}); +const { + b64ToUrlSafeB64, + uInt8ArrayToB64, + strToUint8Array, + hash, +} = require("../encoding"); +const chai = require("chai"); + +const { baseUrl } = module.context; +const applyBaseUrl = baseUrl.replace("/brightid5", "/apply5"); + +const usersColl = arango._collection("users"); +const operationsColl = arango._collection("operations"); + +const should = chai.should(); + +const u1 = nacl.sign.keyPair(); +const u2 = nacl.sign.keyPair(); +const u3 = nacl.sign.keyPair(); + +let { publicKey: sponsorPublicKey, secretKey: sponsorPrivateKey } = + nacl.sign.keyPair(); +let { secretKey: linkAESKey } = nacl.sign.keyPair(); + +const contextId = "0x636D49c1D76ff8E04767C68fe75eC9900719464b".toLowerCase(); +const contextName = "ethereum"; + +function apply(op) { + let resp = request.post(`${baseUrl}/operations`, { + body: op, + json: true, + }); + if (resp.status != 200) { + return resp; + } + resp.status.should.equal(200); + let h = hash(getMessage(op)); + resp.json.data.hash.should.equal(h); + op = operationsColl.document(h); + op = _.omit(op, ["_rev", "_id", "_key", "hash", "state"]); + op.blockTime = op.timestamp; + resp = request.put(`${applyBaseUrl}/operations/${h}`, { + body: op, + json: true, + }); + resp.json.success.should.equal(true); + if ((resp.state = "failed")) { + return resp; + } +} + +describe("errors", function () { + before(function () { + usersColl.truncate(); + operationsColl.truncate(); + [u1, u2, u3].map((u) => { + u.signingKey = uInt8ArrayToB64(Object.values(u.publicKey)); + u.id = b64ToUrlSafeB64(u.signingKey); + db.createUser(u.id, Date.now()); + }); + }); + + after(function () { + usersColl.truncate(); + operationsColl.truncate(); + }); + + it("should throw INVALID_SIGNATURE when operation signed by wrong user", function () { + const timestamp = Date.now(); + let op = { + v: 5, + name: "Add Connection", + id1: u1.id, + id2: u2.id, + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + op.sig2 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u3.secretKey)) + ); + const resp = apply(op); + resp.json.code.should.equal(401); + resp.json.errorNum.should.equal(errors.INVALID_SIGNATURE); + }); + + it("should throw INVALID_COFOUNDERS when try to create group by not connected users", function () { + const timestamp = Date.now(); + const type = "general"; + const url = "http://url.com/dummy"; + const groupId = hash("randomstr"); + + const op = { + v: 5, + name: "Add Group", + group: groupId, + id1: u1.id, + id2: u2.id, + inviteData2: "data", + id3: u3.id, + inviteData3: "data", + url, + type, + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + const resp = apply(op); + resp.json.result.errorNum.should.equal(errors.INVALID_COFOUNDERS); + }); + + it("should throw OperationNotFoundError when the operation does not exist", function () { + const hash = "testHash"; + const resp = request.get(`${baseUrl}/operations/${hash}`); + resp.json.code.should.equal(404); + resp.json.errorNum.should.equal(errors.OPERATION_NOT_FOUND); + resp.json.errorMessage.should.equal(`The operation ${hash} is not found.`); + }); + + it("should throw UserNotFoundError when the user does not exist", function () { + const id = "testId"; + const resp = request.get(`${baseUrl}/users/${id}`); + resp.json.code.should.equal(404); + resp.json.errorNum.should.equal(errors.USER_NOT_FOUND); + resp.json.errorMessage.should.equal(`The user ${id} is not found.`); + }); + + it("handle joi errors (invalid operation)", function () { + const timestamp = Date.now(); + let op = { + v: 5, + name: "Add new Connection", + id1: u1.id, + id2: u2.id, + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + op.sig2 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + const resp = apply(op); + resp.json.errorNum.should.equal(400); + resp.json.code.should.equal(400); + resp.json.errorMessage.should.equal("invalid operation name"); + }); + + it("handle joi errors (missed parameter of an operation)", function () { + const timestamp = Date.now(); + let op = { + v: 5, + name: "Add Connection", + id2: u2.id, + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + op.sig2 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + const resp = apply(op); + resp.json.errorNum.should.equal(400); + resp.json.code.should.equal(400); + resp.json.errorMessage.should.equal('"id1" is required, '); + }); +}); diff --git a/web_services/foxx/apply5/tests/graph.js b/web_services/foxx/apply5/tests/graph.js new file mode 100755 index 00000000..50eae53f --- /dev/null +++ b/web_services/foxx/apply5/tests/graph.js @@ -0,0 +1,36 @@ +"use strict"; + +const db = require("../db.js"); +const arango = require("@arangodb").db; +const connectionsColl = arango._collection("connections"); +const groupsColl = arango._collection("groups"); +const usersInGroupsColl = arango._collection("usersInGroups"); +const usersColl = arango._collection("users"); + +const chai = require("chai"); +const should = chai.should(); + +describe("db graph", function () { + before(function () { + connectionsColl.truncate(); + usersColl.truncate(); + db.createUser("a"); + db.createUser("b"); + }); + after(function () { + connectionsColl.truncate(); + usersColl.truncate(); + }); + it("should be able to retrieve a score for a user", function () { + db.userScore("a").should.equal(0); + }); + it("should be able to create a connection", function () { + db.connect({ id1: "a", id2: "b", level: "already known" }); + }); + it("should be able to remove a connection", function () { + db.removeConnection("b", "a", "duplicate", Date.now()); + }); + it("should be able to re-add a connection", function () { + db.addConnection("b", "a", Date.now()); + }); +}); diff --git a/web_services/foxx/apply5/tests/groups.js b/web_services/foxx/apply5/tests/groups.js new file mode 100755 index 00000000..1a6b68fd --- /dev/null +++ b/web_services/foxx/apply5/tests/groups.js @@ -0,0 +1,270 @@ +"use strict"; + +const db = require("../db.js"); +const errors = require("../errors.js"); +const arango = require("@arangodb").db; +const { hash } = require("../encoding"); + +const connectionsColl = arango._collection("connections"); +const groupsColl = arango._collection("groups"); +const usersInGroupsColl = arango._collection("usersInGroups"); +const usersColl = arango._collection("users"); +const invitationsColl = arango._collection("invitations"); + +const chai = require("chai"); +const should = chai.should(); +const expect = chai.expect; +const url = "http://url.com/dummy"; + +describe("groups", function () { + before(function () { + usersColl.truncate(); + connectionsColl.truncate(); + groupsColl.truncate(); + usersInGroupsColl.truncate(); + invitationsColl.truncate(); + db.createUser("a"); + db.createUser("b"); + db.createUser("c"); + db.createUser("d"); + db.createUser("e"); + db.createUser("f"); + db.addConnection("b", "c", 0); + db.addConnection("b", "d", 0); + db.addConnection("a", "b", 0); + db.addConnection("a", "c", 0); + db.addConnection("a", "d", 0); + }); + after(function () { + usersColl.truncate(); + connectionsColl.truncate(); + groupsColl.truncate(); + usersInGroupsColl.truncate(); + invitationsColl.truncate(); + }); + it("should be able to create a group", function () { + db.createGroup( + "g1", + "b", + "c", + "data", + "d", + "data", + url, + "general", + Date.now() + ); + groupsColl.count().should.equal(1); + const group = groupsColl.any(); + group._key.should.equal("g1"); + group.isNew.should.equal(true); + }); + it("should be able to delete a group", function () { + db.deleteGroup("g1", "b", Date.now()); + groupsColl.count().should.equal(0); + }); + it("should be able to create the group again", function () { + db.createGroup( + "g2", + "b", + "c", + "data", + "d", + "data", + url, + "general", + Date.now() + ); + groupsColl.count().should.equal(1); + groupsColl.any()._key.should.equal("g2"); + }); + it("the two co-founders should be able to join the group", function () { + db.addMembership("g2", "c", Date.now()); + db.addMembership("g2", "d", Date.now()); + }); + it("the group should be upgraded from a new group to a normal group", function () { + groupsColl.count().should.equal(1); + groupsColl.any().isNew.should.equal(false); + }); + + describe("a user connected to all three members of a group", function () { + it("should have three connections", function () { + db.userConnections("a").length.should.equal(3); + }); + it("should not be able to join the group without invitation", function () { + (() => { + db.addMembership("g2", "a", Date.now()); + }).should.throw("not invited to join this group"); + }); + it("should be able to join the group after invitation", function () { + db.invite("b", "a", "g2", "data", Date.now()); + db.addMembership("g2", "a", Date.now()); + usersInGroupsColl.count().should.equal(4); + }); + it("should be able to leave the group", function () { + db.deleteMembership("g2", "a", Date.now()); + usersInGroupsColl.count().should.equal(3); + }); + }); + + describe("inviting", function () { + before(function () { + db.createUser("g"); + db.addConnection("a", "b", 0); + db.addConnection("a", "c", 0); + db.addConnection("a", "d", 0); + db.addConnection("b", "d", 0); + db.addConnection("c", "d", 0); + db.createGroup( + "g3", + "a", + "b", + "data", + "c", + "data", + url, + "general", + Date.now() + ); + db.addMembership("g3", "b", Date.now()); + db.addMembership("g3", "c", Date.now()); + }); + it("no one should be able to join an invite only group without invitation", function () { + (() => { + db.addMembership("g3", "d", Date.now()); + }).should.throw("not invited to join this group"); + }); + it("admins should be able to invite any users to the group", function () { + db.invite("b", "d", "g3", "data", Date.now()); + db.userInvitedGroups("d") + .map((group) => group.id) + .should.deep.equal(["g3"]); + }); + it("invited user should be able to join the group", function () { + db.addMembership("g3", "d", Date.now()); + db.groupMembers("g3").should.include("d"); + db.userInvitedGroups("d").length.should.equal(0); + }); + it("non-admins should not be able to invite others to the group", function () { + (() => { + db.invite("d", "e", "g3", "data", Date.now()); + }).should.throw(errors.NotAdminError); + }); + }); + + describe("dismissing", function () { + before(function () { + db.addConnection("a", "d", 0); + db.addConnection("b", "d", 0); + db.addConnection("c", "d", 0); + db.addConnection("a", "e", 0); + db.addConnection("b", "e", 0); + db.addConnection("c", "e", 0); + db.invite("b", "d", "g3", "data", Date.now()); + db.invite("b", "e", "g3", "data", Date.now()); + db.addMembership("g3", "d", Date.now()); + db.addMembership("g3", "e", Date.now()); + }); + it("non-admins should not be able to dismiss others from the group", function () { + (() => { + db.dismiss("d", "e", "g3", Date.now()); + }).should.throw(errors.NotAdminError); + }); + it("admins should be able to dismiss others from the group", function () { + db.dismiss("b", "d", "g3", Date.now()); + db.groupMembers("g3").should.not.include("d"); + }); + }); + + describe("adding new admins", function () { + before(function () { + db.invite("b", "d", "g3", "data", Date.now()); + db.addMembership("g3", "d", Date.now()); + }); + it("non-admins should not be able to add new admins", function () { + (() => { + db.addAdmin("e", "d", "g3", Date.now()); + }).should.throw(errors.NotAdminError); + }); + it("admins should be able to add new admins", function () { + db.addAdmin("b", "d", "g3", Date.now()); + groupsColl.document("g3").admins.should.include("d"); + }); + it("new admins should be able to dismiss others from the group", function () { + db.dismiss("d", "e", "g3", Date.now()); + db.groupMembers("g3").should.not.include("e"); + }); + it("admins should be removed from admins list when they leave the group", function () { + groupsColl.document("g3").admins.should.include("d"); + db.deleteMembership("g3", "d", Date.now()); + groupsColl.document("g3").admins.should.not.include("d"); + }); + }); + + describe("primary groups", function () { + before(function () { + groupsColl.truncate(); + groupsColl.truncate(); + usersInGroupsColl.truncate(); + db.addConnection("a", "d", 0); + db.addConnection("a", "e", 0); + db.addConnection("e", "c", 0); + db.addConnection("e", "b", 0); + db.createGroup( + "g4", + "a", + "b", + "data", + "c", + "data", + url, + "primary", + Date.now() + ); + db.addMembership("g4", "b", Date.now()); + db.addMembership("g4", "c", Date.now()); + }); + it("users that have primary groups should not be able to create new primary groups", function () { + (() => { + db.createGroup( + "g5", + "a", + "d", + "data", + "e", + "data", + url, + "primary", + Date.now() + ); + }).should.throw(errors.AlreadyHasPrimaryGroupError); + }); + it("users with no primary group should be able to join a primary group", function () { + db.invite("a", "d", "g4", "data", Date.now()); + db.addMembership("g4", "d", Date.now()); + db.userGroups("d") + .map((group) => group.id) + .should.deep.equal(["g4"]); + }); + it("users that have primary groups should not be able to invited to other primary groups", function () { + db.addConnection("e", "f", Date.now()); + db.addConnection("e", "g", Date.now()); + db.createGroup( + "g6", + "e", + "f", + "data", + "g", + "data", + url, + "primary", + Date.now() + ); + db.addMembership("g6", "f", Date.now()); + db.addMembership("g6", "g", Date.now()); + (() => { + db.invite("a", "e", "g4", "data", Date.now()); + }).should.throw(errors.AlreadyHasPrimaryGroupError); + }); + }); +}); diff --git a/web_services/foxx/apply5/tests/links.js b/web_services/foxx/apply5/tests/links.js new file mode 100755 index 00000000..e2a417f4 --- /dev/null +++ b/web_services/foxx/apply5/tests/links.js @@ -0,0 +1,150 @@ +"use strict"; + +const db = require("../db.js"); +const errors = require("../errors.js"); +const arango = require("@arangodb").db; +const query = require("@arangodb").query; +const chai = require("chai"); + +let contextIdsColl; +const usersColl = arango._collection("users"); +const contextsColl = arango._collection("contexts"); +const appsColl = arango._collection("apps"); +const sponsorshipsColl = arango._collection("sponsorships"); +const variablesColl = arango._collection("variables"); +const verificationsColl = arango._collection("verifications"); +const should = chai.should(); + +describe("links & sponsorships", function () { + before(function () { + contextIdsColl = arango._create("testIds"); + usersColl.truncate(); + contextsColl.truncate(); + appsColl.truncate(); + sponsorshipsColl.truncate(); + query` + INSERT { + _key: "testContext", + collection: "testIds" + } IN ${contextsColl} + `; + query` + INSERT { + _key: "testApp", + totalSponsorships: 1, + verification: "BrightID", + context: "testContext" + } IN ${appsColl} + `; + query` + INSERT { + _key: "2" + } IN ${usersColl} + `; + query` + INSERT { + _key: "3" + } IN ${usersColl} + `; + query` + INSERT { + _key: "4" + } IN ${usersColl} + `; + const hashes = JSON.parse( + variablesColl.document("VERIFICATIONS_HASHES").hashes + ); + const block = Math.max(...Object.keys(hashes)); + query` + INSERT { + name: "BrightID", + user: "2", + block: ${block} + } IN ${verificationsColl} + `; + query` + INSERT { + name: "BrightID", + user: "3", + block: ${block} + } IN ${verificationsColl} + `; + query` + INSERT { + name: "BrightID", + user: "4", + block: ${block} + } IN ${verificationsColl} + `; + }); + after(function () { + arango._drop(contextIdsColl); + usersColl.truncate(); + contextsColl.truncate(); + appsColl.truncate(); + sponsorshipsColl.truncate(); + }); + context("linkContextId()", function () { + it("should throw DuplicateContextIdError for used contextId", function () { + db.linkContextId("2", "testContext", "used", 5); + (() => { + db.linkContextId("3", "testContext", "used", 10); + }).should.throw(errors.DuplicateContextIdError); + }); + it("should allow same user to relink used contextIds", function () { + db.linkContextId("2", "testContext", "second", 11); + db.linkContextId("2", "testContext", "used", 10); + }); + it("should return add link if contextId and timestamp are OK", function () { + db.linkContextId("3", "testContext", "testContextId", 10); + db.getUserByContextId(contextIdsColl, "testContextId").should.equal("3"); + }); + it("should not be able to link more than 3 contextIds in a single day", function () { + db.linkContextId("3", "testContext", "testContextId2", 15); + db.linkContextId("3", "testContext", "testContextId3", 20); + (() => { + db.linkContextId("3", "testContext", "testContextId4", 25); + }).should.throw(errors.TooManyLinkRequestError); + }); + it("should be able to link new contextId after 24 hours", function () { + db.linkContextId( + "3", + "testContext", + "testContextId4", + 24 * 3600 * 1000 + 25 + ); + }); + it("should throw UserNotFoundError if user doesn't exist", function () { + (() => { + db.linkContextId("6", "testContext", "newcontextid", 10); + }).should.throw(errors.UserNotFoundError); + }); + }); + context("sponsor()", function () { + it("should be able to sponsor a user if app has unused sponsorships and user is not sponsored before", function () { + db.sponsor({ + name: "Sponsor", + contextId: "2", + app: "testApp", + timestamp: 0, + }); + db.sponsor({ + name: "Spend Sponsorship", + contextId: "2", + app: "testApp", + timestamp: 0, + }); + appsColl.document("testApp").usedSponsorships.should.equal(1); + }); + it("should throw UnusedSponsorshipsError if app has no unused sponsorship", function () { + (() => { + db.sponsor({ + name: "Sponsor", + contextId: "3", + app: "testApp", + timestamp: 0, + }); + }).should.throw(errors.UnusedSponsorshipsError); + }); + }); +}); diff --git a/web_services/foxx/apply5/tests/operations.js b/web_services/foxx/apply5/tests/operations.js new file mode 100755 index 00000000..d4515eb2 --- /dev/null +++ b/web_services/foxx/apply5/tests/operations.js @@ -0,0 +1,1028 @@ +"use strict"; + +const secp256k1 = require("secp256k1"); +const createKeccakHash = require("keccak"); +const db = require("../db.js"); +const errors = require("../errors.js"); +const _ = require("lodash"); +const { getMessage } = require("../operations"); +const { db: arango, query } = require("@arangodb"); +const request = require("@arangodb/request"); +const nacl = require("tweetnacl"); +nacl.setPRNG(function (x, n) { + for (let i = 0; i < n; i++) { + x[i] = Math.floor(Math.random() * 256); + } +}); +const { + b64ToUrlSafeB64, + uInt8ArrayToB64, + strToUint8Array, + b64ToUint8Array, + hash, + pad32, + addressToBytes32, +} = require("../encoding"); + +const { baseUrl } = module.context; +const applyBaseUrl = baseUrl.replace("/brightid5", "/apply5"); + +let hashes; +let contextIdsColl, contextIdsColl2; +const connectionsColl = arango._collection("connections"); +const groupsColl = arango._collection("groups"); +const usersInGroupsColl = arango._collection("usersInGroups"); +const usersColl = arango._collection("users"); +const operationsColl = arango._collection("operations"); +const contextsColl = arango._collection("contexts"); +const appsColl = arango._collection("apps"); +const sponsorshipsColl = arango._collection("sponsorships"); +const operationsHashesColl = arango._collection("operationsHashes"); +const invitationsColl = arango._collection("invitations"); +const verificationsColl = arango._collection("verifications"); +const operationCountersColl = arango._collection("operationCounters"); +const variablesColl = arango._collection("variables"); + +const chai = require("chai"); +const should = chai.should(); + +const u1 = nacl.sign.keyPair(); +const u2 = nacl.sign.keyPair(); +const u3 = nacl.sign.keyPair(); +const u4 = nacl.sign.keyPair(); +const u5 = nacl.sign.keyPair(); +const u6 = nacl.sign.keyPair(); +const u7 = nacl.sign.keyPair(); + +let { publicKey: sponsorPublicKey, secretKey: sponsorPrivateKey } = + nacl.sign.keyPair(); +let { secretKey: linkAESKey } = nacl.sign.keyPair(); + +const contextId = "0x636D49c1D76ff8E04767C68fe75eC9900719464b".toLowerCase(); +const contextId2 = "0x636D49c1D76ff8E04767C68fe75eC9900719464a".toLowerCase(); +const contextName = "ethereum"; +const app = "ethereum"; + +const soulboundContextName = "soulboundToken"; +const soulboundApp = "soulboundToken"; + +const soulboundMessage = "it's a test message."; + +function apply(op) { + let resp = request.post(`${baseUrl}/operations`, { + body: op, + json: true, + }); + resp.status.should.equal(200); + let h = hash(getMessage(op)); + resp.json.data.hash.should.equal(h); + op = operationsColl.document(h); + op = _.omit(op, ["_rev", "_id", "_key", "hash", "state"]); + op.blockTime = op.timestamp; + resp = request.put(`${applyBaseUrl}/operations/${h}`, { + body: op, + json: true, + }); + resp.json.success.should.equal(true); + return resp; +} + +describe("operations", function () { + before(function () { + contextIdsColl = arango._collection(contextName); + if (contextIdsColl) { + contextIdsColl.truncate(); + } else { + contextIdsColl = arango._create(contextName); + } + contextIdsColl2 = arango._collection(soulboundContextName); + if (contextIdsColl2) { + contextIdsColl2.truncate(); + } else { + contextIdsColl2 = arango._create(soulboundContextName); + } + operationsHashesColl.truncate(); + usersColl.truncate(); + connectionsColl.truncate(); + groupsColl.truncate(); + usersInGroupsColl.truncate(); + operationsColl.truncate(); + contextsColl.truncate(); + appsColl.truncate(); + sponsorshipsColl.truncate(); + invitationsColl.truncate(); + verificationsColl.truncate(); + [u1, u2, u3, u4, u5, u6, u7].forEach((u) => { + u.signingKey = uInt8ArrayToB64(Object.values(u.publicKey)); + u.id = b64ToUrlSafeB64(u.signingKey); + db.createUser(u.id, Date.now()); + }); + contextsColl.insert({ + _key: contextName, + collection: contextName, + linkAESKey: uInt8ArrayToB64(Object.values(linkAESKey)), + idsAsHex: true, + }); + appsColl.insert({ + _key: app, + context: contextName, + totalSponsorships: 5, + verification: "BrightID", + idsAsHex: true, + sponsorPublicKey: uInt8ArrayToB64(Object.values(sponsorPublicKey)), + }); + contextsColl.insert({ + _key: soulboundContextName, + collection: soulboundContextName, + soulboundMessage, + linkAESKey: uInt8ArrayToB64(Object.values(linkAESKey)), + soulbound: true, + }); + appsColl.insert({ + _key: soulboundApp, + context: soulboundContextName, + totalSponsorships: 1, + verification: "BrightID", + soulbound: true, + sponsorPublicKey: uInt8ArrayToB64(Object.values(sponsorPublicKey)), + }); + const hashes = JSON.parse( + variablesColl.document("VERIFICATIONS_HASHES").hashes + ); + const block = Math.max(...Object.keys(hashes)); + verificationsColl.insert({ + name: "BrightID", + user: u1.id, + block, + }); + verificationsColl.insert({ + name: "BrightID", + user: u3.id, + block, + }); + verificationsColl.insert({ + name: "SeedConnected", + user: u1.id, + rank: 3, + block, + }); + verificationsColl.insert({ + name: "BrightID", + user: u7.id, + block, + }); + operationCountersColl.truncate(); + }); + + after(function () { + operationsHashesColl.truncate(); + contextsColl.truncate(); + appsColl.truncate(); + arango._drop(contextIdsColl); + arango._drop(contextIdsColl2); + usersColl.truncate(); + connectionsColl.truncate(); + groupsColl.truncate(); + usersInGroupsColl.truncate(); + operationsColl.truncate(); + sponsorshipsColl.truncate(); + invitationsColl.truncate(); + verificationsColl.truncate(); + operationCountersColl.truncate(); + }); + + it('should be able to "Add Connection"', function () { + const connect = (u1, u2) => { + const timestamp = Date.now(); + let op = { + v: 5, + name: "Add Connection", + id1: u1.id, + id2: u2.id, + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), u1.secretKey) + ) + ); + op.sig2 = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), u2.secretKey) + ) + ); + apply(op); + }; + connect(u1, u2); + connect(u1, u3); + connect(u2, u3); + connect(u2, u4); + connect(u3, u4); + db.userConnections(u1.id).length.should.equal(2); + db.userConnections(u2.id).length.should.equal(3); + db.userConnections(u3.id).length.should.equal(3); + }); + + it('should be able to "Remove Connection"', function () { + db.connect({ id1: u3.id, id2: u2.id, level: "already known" }); + const timestamp = Date.now(); + const reason = "duplicate"; + + let op = { + v: 5, + name: "Remove Connection", + id1: u2.id, + id2: u3.id, + reason, + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + apply(op); + connectionsColl + .firstExample({ + _from: "users/" + u2.id, + _to: "users/" + u3.id, + }) + .reportReason.should.equal(reason); + connectionsColl + .firstExample({ + _from: "users/" + u3.id, + _to: "users/" + u2.id, + }) + .level.should.equal("already known"); + }); + + it('should be able to "Add Group"', function () { + const timestamp = Date.now(); + const type = "general"; + const url = "http://url.com/dummy"; + const groupId = hash("randomstr"); + + const op = { + v: 5, + name: "Add Group", + group: groupId, + id1: u1.id, + id2: u2.id, + inviteData2: "data", + id3: u3.id, + inviteData3: "data", + url, + type, + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + apply(op); + + const members = db.groupMembers(groupId); + members.should.include(u1.id); + members.should.not.include(u2.id); + members.should.not.include(u3.id); + }); + + it('should be able to "Add Membership"', function () { + const groupId = db.userGroups(u1.id)[0].id; + [u2, u3].map((u) => { + const timestamp = Date.now(); + const op = { + v: 5, + name: "Add Membership", + id: u.id, + group: groupId, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u.secretKey)) + ); + apply(op); + }); + const members = db.groupMembers(groupId); + members.should.include(u2.id); + members.should.include(u3.id); + }); + + it('should be able to "Remove Membership"', function () { + const timestamp = Date.now(); + const groupId = db.userGroups(u1.id)[0].id; + const op = { + v: 5, + name: "Remove Membership", + id: u1.id, + group: groupId, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + apply(op); + + const members = db.groupMembers(groupId, false); + members.should.not.include(u1.id); + members.should.include(u2.id); + members.should.include(u3.id); + }); + + it('admins should be able to "Invite" someone to the group', function () { + const timestamp = Date.now(); + const groupId = db.userGroups(u2.id)[0].id; + const data = "some data"; + const op = { + v: 5, + name: "Invite", + inviter: u2.id, + invitee: u4.id, + group: groupId, + data, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + apply(op); + invitationsColl + .byExample({ + _from: "users/" + u4.id, + _to: "groups/" + groupId, + }) + .count() + .should.equal(1); + }); + + it('admins should be able to "Dismiss" someone from the group', function () { + const timestamp = Date.now(); + const groupId = db.userGroups(u2.id)[0].id; + db.addMembership(groupId, u4.id, Date.now()); + db.groupMembers(groupId).should.include(u4.id); + const op = { + v: 5, + name: "Dismiss", + dismisser: u2.id, + dismissee: u4.id, + group: groupId, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + apply(op); + db.groupMembers(groupId).should.not.include(u4.id); + }); + + it('admins should be able to "Add Admin" to the group', function () { + const timestamp = Date.now(); + const groupId = db.userGroups(u2.id)[0].id; + db.invite(u2.id, u4.id, groupId, "data", Date.now()); + db.addMembership(groupId, u4.id, Date.now()); + db.groupMembers(groupId).should.include(u4.id); + const op = { + v: 5, + name: "Add Admin", + id: u2.id, + admin: u4.id, + group: groupId, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + apply(op); + groupsColl.document(groupId).admins.should.include(u4.id); + }); + + it('admins should be able "Update Group" to edit name and photo for groups', function () { + const newUrl = "http://url.com/newDummyUrl"; + const timestamp = Date.now(); + const groupId = db.userGroups(u2.id)[0].id; + const op = { + v: 5, + name: "Update Group", + id: u2.id, + url: newUrl, + group: groupId, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + apply(op); + groupsColl.document(groupId).url.should.equal(newUrl); + }); + + it("should not be able to make a recovery connection when the other side connection is not equal to 'recovery' or 'already known'", function () { + const timestamp = Date.now(); + + let op = { + v: 5, + name: "Connect", + id1: u1.id, + id2: u2.id, + level: "recovery", + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + const resp = apply(op); + resp.json.result.errorNum.should.equal( + errors.INELIGIBLE_RECOVERY_CONNECTION + ); + connectionsColl + .firstExample({ + _from: "users/" + u1.id, + _to: "users/" + u2.id, + }) + .level.should.equal("just met"); + }); + + it('should be able to "Set Trusted Connections"', function () { + db.connect({ id1: u2.id, id2: u1.id, level: "already known" }); + db.connect({ id1: u3.id, id2: u1.id, level: "already known" }); + const timestamp = Date.now(); + const op = { + v: 5, + name: "Set Trusted Connections", + id: u1.id, + trusted: [u2.id, u3.id], + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + apply(op); + + connectionsColl + .firstExample({ + _from: "users/" + u1.id, + _to: "users/" + u2.id, + }) + .level.should.equal("recovery"); + connectionsColl + .firstExample({ + _from: "users/" + u1.id, + _to: "users/" + u3.id, + }) + .level.should.equal("recovery"); + }); + + it('should be able to "Set Signing Key"', function () { + const timestamp = Date.now(); + const op = { + v: 5, + name: "Set Signing Key", + id: u1.id, + id1: u2.id, + id2: u3.id, + signingKey: u4.signingKey, + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + op.sig2 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u3.secretKey)) + ); + apply(op); + db.loadUser(u1.id).signingKeys.should.deep.equal([u4.signingKey]); + u1.secretKey = u4.secretKey; + }); + + it('should be able to "Link ContextId"', function () { + const timestamp = Date.now(); + const op = { + v: 5, + name: "Link ContextId", + context: contextName, + timestamp, + id: u1.id, + contextId, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u4.secretKey)) + ); + apply(op); + db.getContextIdsByUser(contextIdsColl, u1.id)[0].should.equal(contextId); + }); + + it('unverified users should not be able to "Link ContextId"', function () { + const timestamp = Date.now(); + const op = { + v: 5, + name: "Link ContextId", + context: contextName, + timestamp, + id: u2.id, + contextId: contextId2, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + const resp = apply(op); + resp.json.state.should.equal('failed'); + resp.json.result.errorNum.should.equal(errors.NOT_VERIFIED); + }); + + it("should be able to linking with Ethereum-signed messages for the soulbound apps", function () { + const exampleSig = { + address: "0xcc15be495d8c8996eefdfc78b2b23ba2fa92d67b", + msg: "0x6974277320612074657374206d6573736167652e", + sig: "e79413f9425cef5771d73321554f96483ef491e7579e85e518fcf84e0948646c393f9afed2de78fa22bf32b0fa4c66cec4d441b90c380f4f67bf8395a551e1111c", + }; + + const timestamp = Date.now(); + const op = { + v: 5, + name: "Link ContextId", + context: soulboundContextName, + timestamp, + id: u3.id, + contextId: exampleSig.sig, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u3.secretKey)) + ); + const res = apply(op); + db.getContextIdsByUser(contextIdsColl2, u3.id)[0].should.equal( + exampleSig.address + ); + let resp = request.get( + `${baseUrl}/verifications/${soulboundApp}/${exampleSig.address}`, + { + qs: { + signed: "nacl", + }, + json: true, + } + ); + resp.status.should.equal(200); + resp.json.data.unique.should.equal(true); + }); + + it('should be able to "Connect"', function () { + const timestamp = Date.now(); + + let op = { + v: 5, + name: "Connect", + id1: u1.id, + id2: u2.id, + level: "just met", + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + apply(op); + connectionsColl + .firstExample({ + _from: "users/" + u1.id, + _to: "users/" + u2.id, + }) + .level.should.equal("just met"); + }); + + it('should be able to report using "Connect" by providing requestProof', function () { + const timestamp = Date.now(); + + const requestProofMessage = u1.id + "|" + timestamp; + const requestProof = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(requestProofMessage), u1.secretKey) + ) + ); + let op = { + v: 5, + name: "Connect", + id1: u2.id, + id2: u1.id, + level: "reported", + reportReason: "spammer", + requestProof, + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + apply(op); + const conn = connectionsColl.firstExample({ + _from: "users/" + u2.id, + _to: "users/" + u1.id, + }); + conn.level.should.equal("reported"); + conn.requestProof.should.equal(requestProof); + }); + + it('should be able to "Add Signing Key"', function () { + const addSigningKey = (u, signingKey) => { + const timestamp = Date.now(); + const op = { + v: 5, + id: u.id, + name: "Add Signing Key", + signingKey, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u.secretKey)) + ); + apply(op); + }; + addSigningKey(u2, u5.signingKey); + addSigningKey(u2, u6.signingKey); + db.loadUser(u2.id).signingKeys.should.deep.equal([ + u2.signingKey, + u5.signingKey, + u6.signingKey, + ]); + }); + + it('should be able to "Remove Signing Key"', function () { + const timestamp = Date.now(); + const op = { + v: 5, + id: u2.id, + name: "Remove Signing Key", + signingKey: u5.signingKey, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + apply(op); + db.loadUser(u2.id).signingKeys.should.deep.equal([ + u2.signingKey, + u6.signingKey, + ]); + }); + + it("should be able to sign an operation using new Signing Key", function () { + const timestamp = Date.now(); + let op = { + v: 5, + name: "Connect", + id1: u2.id, + id2: u3.id, + level: "recovery", + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u6.secretKey)) + ); + apply(op); + db.userConnections(u2.id) + .filter((u) => u.id == u3.id)[0] + .level.should.equal("recovery"); + }); + + it('should be able to "Remove All Signing Keys"', function () { + const timestamp = Date.now(); + const op = { + v: 5, + id: u2.id, + name: "Remove All Signing Keys", + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u6.secretKey)) + ); + apply(op); + db.loadUser(u2.id).signingKeys.should.deep.equal([u6.signingKey]); + }); + + describe("Sponsoring and getting verification", function () { + it('apps should be able to "Sponsor" first then clients "Spend Sponsorship"', function () { + const contextId = "0x79aF508C9698076Bc1c2DfA224f7829e9768B11E"; + let op1 = { + name: "Sponsor", + contextId, + app, + timestamp: Date.now(), + v: 5, + }; + const message1 = getMessage(op1); + op1.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message1), sponsorPrivateKey) + ) + ); + const r = apply(op1); + let resp1 = request.get(`${baseUrl}/sponsorships/${contextId}`); + resp1.json.data.appHasAuthorized.should.equal(true); + resp1.json.data.spendRequested.should.equal(false); + + let op2 = { + name: "Spend Sponsorship", + contextId: contextId.toLowerCase(), + app, + timestamp: Date.now(), + v: 5, + }; + const r2 = apply(op2); + let resp3 = request.get(`${baseUrl}/sponsorships/${contextId}`); + resp3.json.data.appHasAuthorized.should.equal(true); + resp3.json.data.spendRequested.should.equal(true); + appsColl.document(app).usedSponsorships.should.equal(1); + }); + + it('clients should be able to "Spend Sponsorship" first then apps "Sponsor"', function () { + const contextId = "0x79aF508C9698076Bc1c2DfA224f7829e9768B11D"; + let op1 = { + name: "Spend Sponsorship", + contextId, + app, + timestamp: Date.now(), + v: 5, + }; + apply(op1); + let resp1 = request.get(`${baseUrl}/sponsorships/${contextId}`); + resp1.json.data.spendRequested.should.equal(true); + resp1.json.data.appHasAuthorized.should.equal(false); + + let op2 = { + name: "Sponsor", + contextId: contextId.toLowerCase(), + app, + timestamp: Date.now(), + v: 5, + }; + const message3 = getMessage(op2); + op2.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message3), sponsorPrivateKey) + ) + ); + apply(op2); + let resp3 = request.get( + `${baseUrl}/sponsorships/${contextId.toLowerCase()}` + ); + resp3.json.data.appHasAuthorized.should.equal(true); + resp3.json.data.spendRequested.should.equal(true); + appsColl.document(app).usedSponsorships.should.equal(2); + }); + + it("should reject the duplicate sponsor requests which are received recently", function () { + const contextId = "0x79aF508C9698076Bc1c2DfA224f7829e9768B11D"; + let op = { + name: "Sponsor", + contextId, + app, + timestamp: Date.now(), + v: 5, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), sponsorPrivateKey) + ) + ); + let resp = request.post(`${baseUrl}/operations`, { + body: op, + json: true, + }); + resp.status.should.equal(403); + resp.json.errorNum.should.equal(errors.SPONSOR_REQUESTED_RECENTLY); + }); + + it("should reject the sponsor requests which are already sponsored", function () { + const contextId = "0x79aF508C9698076Bc1c2DfA224f7829e9768B11F"; + // insert dummy sponsorship + sponsorshipsColl.insert({ + _from: "users/0", + _to: `apps/${app}`, + appId: contextId, + appHasAuthorized: true, + spendRequested: true, + timestamp: Date.now(), + }); + + let op = { + name: "Sponsor", + contextId, + app, + timestamp: Date.now(), + v: 5, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), sponsorPrivateKey) + ) + ); + let resp = request.post(`${baseUrl}/operations`, { + body: op, + json: true, + }); + resp.status.should.equal(403); + resp.json.errorNum.should.equal(errors.SPONSORED_BEFORE); + }); + + it("return not sponsored for the unlinked and not sponsored contextid", function () { + const contextId = "0x51E4093bb8DA34AdD694A152635bE8e38F4F1a29"; + let resp = request.get( + `${baseUrl}/verifications/${app}/${contextId.toLowerCase()}`, + { + qs: { + signed: "eth", + timestamp: "seconds", + }, + json: true, + } + ); + resp.json.errorNum.should.equal(errors.NOT_SPONSORED); + }); + + it("return contextid not found for the unlinked and sponsored contextid", function () { + const contextId = "0x51E4093bb8DA34AdD694A152635bE8e38F4F1a29"; + let op = { + name: "Sponsor", + contextId: contextId.toLowerCase(), + app, + timestamp: Date.now(), + v: 5, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), sponsorPrivateKey) + ) + ); + apply(op); + + let resp = request.get( + `${baseUrl}/verifications/${app}/${contextId.toLowerCase()}`, + { + qs: { + signed: "eth", + timestamp: "seconds", + }, + json: true, + } + ); + resp.json.errorNum.should.equal(errors.CONTEXTID_NOT_FOUND); + }); + + it('when the client sends "Link ContextId", the app should be able to get verification (the client should check sponsorships status)', function () { + const contextId = "0x51E4093bb8DA34AdD694A152635bE8e38F4F1a30"; + + const op = { + name: "Link ContextId", + context: contextName, + id: u1.id, + contextId: contextId.toLowerCase(), + timestamp: Date.now(), + v: 5, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), u1.secretKey) + ) + ); + apply(op); + let resp = request.get( + `${baseUrl}/verifications/${app}/${contextId.toLowerCase()}`, + { + qs: { + signed: "eth", + timestamp: "seconds", + }, + json: true, + } + ); + resp.json.data.unique.should.equal(true); + }); + }); + + describe("Checking verifications signatures", function () { + it("nacl signature", function () { + const contextId = "0x51E4093bb8DA34AdD694A152635bE8e38F4F1a30"; + + let resp = request.get( + `${baseUrl}/verifications/${app}/${contextId.toLowerCase()}`, + { + qs: { + signed: "nacl", + }, + json: true, + } + ); + resp.status.should.equal(200); + resp.json.data.unique.should.equal(true); + const message = + resp.json.data.app + "," + resp.json.data.contextIds.join(","); + nacl.sign.detached + .verify( + strToUint8Array(message), + b64ToUint8Array(resp.json.data.sig), + b64ToUint8Array(resp.json.data.publicKey) + ) + .should.equal(true); + }); + + it("eth signature", function () { + const contextId = "0x51E4093bb8DA34AdD694A152635bE8e38F4F1a30"; + + let resp = request.get( + `${baseUrl}/verifications/${app}/${contextId.toLowerCase()}`, + { + qs: { + signed: "eth", + }, + json: true, + } + ); + resp.status.should.equal(200); + resp.json.data.unique.should.equal(true); + + let message = + pad32(resp.json.data.app) + + resp.json.data.contextIds.map(addressToBytes32).join(""); + message = Buffer.from(message, "binary").toString("hex"); + message = new Uint8Array( + createKeccakHash("keccak256").update(message, "hex").digest() + ); + + let signature = resp.json.data.sig.r + resp.json.data.sig.s; + signature = new Uint8Array(Buffer.from(signature, "hex")); + + const publicKey = new Uint8Array( + Buffer.from(resp.json.data.publicKey, "hex") + ); + + secp256k1.ecdsaVerify(signature, message, publicKey).should.equal(true); + }); + + + it('should throw error because of XOR(id, contextId)', function () { + const contextId = "0x79aF508C9698076Bc1c2DfA224f7829e9768B11E"; + let op = { + name: "Sponsor", + app: "idchain", + timestamp: Date.now(), + id: u1.id, + contextId: contextId, + v: 5 + } + + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + + let resp = request.post(`${baseUrl}/operations`, { + body: op, + json: true, + }); + + resp.status.should.equal(400); + + }) + + it('should accept and apply new sponsor operation without contextId', function () { + let op = { + name: "Sponsor", + app: app, + timestamp: Date.now(), + id: u7.id, + v: 5 + } + + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u7.secretKey)) + ); + + const resp = apply(op); + console.log(resp) + resp.json.state.should.equal("applied"); + + }) + + + + }); +}); diff --git a/web_services/foxx/apply5/tests/replay_attack.js b/web_services/foxx/apply5/tests/replay_attack.js new file mode 100755 index 00000000..dc392d5f --- /dev/null +++ b/web_services/foxx/apply5/tests/replay_attack.js @@ -0,0 +1,115 @@ +"use strict"; + +const stringify = require("fast-json-stable-stringify"); +const arango = require("@arangodb").db; +const { getMessage } = require("../operations"); +const errors = require("../errors"); +const request = require("@arangodb/request"); +const nacl = require("tweetnacl"); +nacl.setPRNG(function (x, n) { + for (let i = 0; i < n; i++) { + x[i] = Math.floor(Math.random() * 256); + } +}); +const { + strToUint8Array, + uInt8ArrayToB64, + b64ToUrlSafeB64, + hash, +} = require("../encoding"); +const db = require("../db.js"); + +const { baseUrl } = module.context; +const applyBaseUrl = baseUrl.replace("/brightid5", "/apply5"); + +const connectionsColl = arango._collection("connections"); +const groupsColl = arango._collection("groups"); +const usersInGroupsColl = arango._collection("usersInGroups"); +const usersColl = arango._collection("users"); +const operationsColl = arango._collection("operations"); + +const chai = require("chai"); +const should = chai.should(); + +const u1 = nacl.sign.keyPair(); +const u2 = nacl.sign.keyPair(); +const u3 = nacl.sign.keyPair(); +[u1, u2, u3].map((u) => { + u.signingKey = uInt8ArrayToB64(Object.values(u.publicKey)); + u.id = b64ToUrlSafeB64(u.signingKey); +}); + +describe("replay attack on operations", function () { + before(function () { + usersColl.truncate(); + connectionsColl.truncate(); + groupsColl.truncate(); + usersInGroupsColl.truncate(); + operationsColl.truncate(); + db.createUser(u1.id, u1.signingKey); + db.createUser(u2.id, u2.signingKey); + db.createUser(u3.id, u3.signingKey); + }); + after(function () { + usersColl.truncate(); + connectionsColl.truncate(); + groupsColl.truncate(); + usersInGroupsColl.truncate(); + operationsColl.truncate(); + }); + + it("should not be able to add an operation twice", function () { + const timestamp = Date.now(); + + let op = { + name: "Add Connection", + id1: u1.id, + id2: u2.id, + timestamp, + v: 5, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + op.sig2 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + const resp1 = request.post(`${baseUrl}/operations`, { + body: op, + json: true, + }); + resp1.status.should.equal(200); + const h = hash(message); + resp1.json.data.hash.should.equal(h); + + op = operationsColl.document(h); + delete op._rev; + delete op._id; + delete op._key; + delete op.hash; + delete op.state; + op.blockTime = op.timestamp; + const resp2 = request.put(`${applyBaseUrl}/operations/${h}`, { + body: op, + json: true, + }); + resp2.json.success.should.equal(true); + resp2.json.state.should.equal("applied"); + delete op.blockTime; + + const resp3 = request.post(`${baseUrl}/operations`, { + body: op, + json: true, + }); + resp3.status.should.equal(403); + resp3.json.errorNum.should.equal(errors.OPERATION_APPLIED_BEFORE); + op.blockTime = op.timestamp; + + const resp4 = request.put(`${applyBaseUrl}/operations/${h}`, { + body: op, + json: true, + }); + resp4.json.result.errorNum.should.equal(errors.OPERATION_APPLIED_BEFORE); + }); +}); diff --git a/web_services/foxx/apply5/tests/sponsorship.js b/web_services/foxx/apply5/tests/sponsorship.js new file mode 100644 index 00000000..e71e06a5 --- /dev/null +++ b/web_services/foxx/apply5/tests/sponsorship.js @@ -0,0 +1,150 @@ +"use strict"; + +const db = require("../db.js"); +const arango = require("@arangodb").db; +const errors = require("../errors.js"); +const nacl = require("tweetnacl"); +nacl.setPRNG(function (x, n) { + for (let i = 0; i < n; i++) { + x[i] = Math.floor(Math.random() * 256); + } +}); + +const { b64ToUrlSafeB64, uInt8ArrayToB64 } = require("../encoding"); + +const usersColl = arango._collection("users"); +const appsColl = arango._collection("apps"); +const sponsorshipsColl = arango._collection("sponsorships"); +const verificationsColl = arango._collection("verifications"); +const variablesColl = arango._collection("variables"); +const contextsColl = arango._collection("contexts"); + +const chai = require("chai"); +const should = chai.should(); + +const u1 = nacl.sign.keyPair(); +const u2 = nacl.sign.keyPair(); + +const contextName = "testapp"; +const app = "testapp"; + +let { publicKey: sponsorPublicKey, secretKey: sponsorPrivateKey } = + nacl.sign.keyPair(); +let { secretKey: linkAESKey } = nacl.sign.keyPair(); + +describe("New sponsorship routine", function () { + before(function () { + usersColl.truncate(); + appsColl.truncate(); + sponsorshipsColl.truncate(); + verificationsColl.truncate(); + contextsColl.truncate(); + + [u1, u2].forEach((u) => { + u.signingKey = uInt8ArrayToB64(Object.values(u.publicKey)); + u.id = b64ToUrlSafeB64(u.signingKey); + db.createUser(u.id, Date.now()); + }); + + contextsColl.insert({ + _key: contextName, + collection: contextName, + linkAESKey: uInt8ArrayToB64(Object.values(linkAESKey)), + idsAsHex: true, + }); + + appsColl.insert({ + _key: app, + context: contextName, + totalSponsorships: 5, + verification: "meets.rank>1 and bitu.score>2", + idsAsHex: true, + sponsorPublicKey: uInt8ArrayToB64(Object.values(sponsorPublicKey)), + }); + + const hashes = JSON.parse( + variablesColl.document("VERIFICATIONS_HASHES").hashes + ); + const block = Math.max(...Object.keys(hashes)); + + verificationsColl.insert({ + name: "bitu", + user: u1.id, + score: 3, + block, + }); + + verificationsColl.insert({ + name: "meets", + user: u1.id, + rank: 2, + block, + }); + + verificationsColl.insert({ + name: "meets", + user: u2.id, + rank: 2, + block, + }); + }); + after(function () { + usersColl.truncate(); + appsColl.truncate(); + sponsorshipsColl.truncate(); + verificationsColl.truncate(); + variablesColl.truncate(); + contextsColl.truncate(); + }); + + describe("Unit tests", function () { + describe("db.isVerifiedFor", function () { + it("should return true for the expected expr", function () { + const sampleExpr = `meets.rank>1 and bitu.score>2`; + db.isVerifiedFor(u1.id, sampleExpr).should.equal(true); + }); + it("should return false for the expected expr", function () { + const sampleExpr = `meets.rank>1 and bitu.score>3`; + db.isVerifiedFor(u1.id, sampleExpr).should.equal(false); + }); + it("should return false for the expected expr", function () { + const sampleExpr = `meets.rank>1 and bitu.score>2`; + db.isVerifiedFor(u2.id, sampleExpr).should.equal(false); + }); + }); + describe("db.sponor", function () { + it("should accept new sponsor operations", function () { + db.isSponsored(u1.id).should.equal(false); + const operation = { + id: u1.id, + app: app, + timestamp: Date.now(), + }; + db.sponsor(operation); + db.isSponsored(u1.id).should.equal(true); + }); + it("should reject sponsor operation with verification error", function () { + db.isSponsored(u2.id).should.equal(false); + const operation = { + id: u2.id, + app: app, + timestamp: Date.now(), + }; + (() => { + db.sponsor(operation) + }).should.throw(errors.NOT_VERIFIED); + }); + it("should reject sponsor operation with already sponsored error", function () { + db.isSponsored(u1.id).should.equal(true); + const operation = { + id: u1.id, + app: app, + timestamp: Date.now(), + }; + (() => { + db.sponsor(operation) + }).should.throw(errors.SPONSORED_BEFORE); + }); + }); + }); +}); diff --git a/web_services/foxx/apply5/tests/time_window.js b/web_services/foxx/apply5/tests/time_window.js new file mode 100755 index 00000000..fdd306f8 --- /dev/null +++ b/web_services/foxx/apply5/tests/time_window.js @@ -0,0 +1,71 @@ +"use strict"; + +const operations = require("../operations.js"); +const db = require("../db.js"); +const errors = require("../errors"); +const arango = require("@arangodb").db; + +const usersColl = arango._collection("users"); +const connectionsColl = arango._collection("connections"); +const verificationsColl = arango._collection("verifications"); +const operationCountersColl = arango._collection("operationCounters"); +const variablesColl = arango._collection("variables"); + +const chai = require("chai"); +const should = chai.should(); + +describe("time window", function () { + before(function () { + usersColl.truncate(); + connectionsColl.truncate(); + verificationsColl.truncate(); + usersColl.insert({ _key: "a" }); + usersColl.insert({ _key: "b" }); + usersColl.insert({ _key: "c" }); + const hashes = JSON.parse(variablesColl.document("VERIFICATIONS_HASHES").hashes); + const block = Math.max(...Object.keys(hashes)); + verificationsColl.insert({ name: "BrightID", user: "a", block }); + operationCountersColl.truncate(); + }); + after(function () { + usersColl.truncate(); + connectionsColl.truncate(); + verificationsColl.truncate(); + operationCountersColl.truncate(); + }); + it("should get error after limit", function () { + operations.checkLimits({ name: "Add Group", id1: "a" }, 100, 2); + operations.checkLimits({ name: "Remove Group", id: "a" }, 100, 2); + (() => { + operations.checkLimits({ name: "Add Membership", id: "a" }, 100, 2); + }).should.throw(errors.TooManyOperationsError); + }); + it("unverified users should have shared limit", function () { + operations.checkLimits({ name: "Add Group", id1: "b" }, 100, 2); + operations.checkLimits({ name: "Add Group", id1: "c" }, 100, 2); + (() => { + operations.checkLimits({ name: "Add Membership", id: "b" }, 100, 2); + }).should.throw(errors.TooManyOperationsError); + }); + it("connecting to first verified user should set parent", function () { + db.addConnection("a", "c", 1); + usersColl.document("c").parent.should.equal("a"); + }); + it("unverified users with parent should have different limit", function () { + (() => { + operations.checkLimits({ name: "Add Membership", id: "b" }, 100, 2); + }).should.throw(errors.TooManyOperationsError); + operations.checkLimits({ name: "Add Group", id1: "c" }, 100, 2); + operations.checkLimits({ name: "Add Group", id1: "c" }, 100, 2); + (() => { + operations.checkLimits({ name: "Add Membership", id: "c" }, 100, 2); + }).should.throw(errors.TooManyOperationsError); + }); + it("every app should have different limit", function () { + operations.checkLimits({ name: "Sponsor", app: "app1" }, 100, 2); + operations.checkLimits({ name: "Sponsor", app: "app1" }, 100, 2); + (() => { + operations.checkLimits({ name: "Sponsor", app: "app1" }, 100, 2); + }).should.throw(errors.TooManyOperationsError); + }); +}); diff --git a/web_services/foxx/apply6.zip b/web_services/foxx/apply6.zip deleted file mode 100644 index 20fb9897..00000000 Binary files a/web_services/foxx/apply6.zip and /dev/null differ diff --git a/web_services/foxx/brightid/WISchnorrClient.js b/web_services/foxx/apply6/WISchnorrClient.js similarity index 100% rename from web_services/foxx/brightid/WISchnorrClient.js rename to web_services/foxx/apply6/WISchnorrClient.js diff --git a/web_services/foxx/brightid/WISchnorrServer.js b/web_services/foxx/apply6/WISchnorrServer.js similarity index 100% rename from web_services/foxx/brightid/WISchnorrServer.js rename to web_services/foxx/apply6/WISchnorrServer.js diff --git a/web_services/foxx/brightid/apply.js b/web_services/foxx/apply6/apply.js similarity index 100% rename from web_services/foxx/brightid/apply.js rename to web_services/foxx/apply6/apply.js diff --git a/web_services/foxx/brightid/db.js b/web_services/foxx/apply6/db.js similarity index 100% rename from web_services/foxx/brightid/db.js rename to web_services/foxx/apply6/db.js diff --git a/web_services/foxx/brightid/encoding.js b/web_services/foxx/apply6/encoding.js similarity index 100% rename from web_services/foxx/brightid/encoding.js rename to web_services/foxx/apply6/encoding.js diff --git a/web_services/foxx/brightid/errors.js b/web_services/foxx/apply6/errors.js similarity index 100% rename from web_services/foxx/brightid/errors.js rename to web_services/foxx/apply6/errors.js diff --git a/web_services/foxx/brightid/index.js b/web_services/foxx/apply6/index.js similarity index 100% rename from web_services/foxx/brightid/index.js rename to web_services/foxx/apply6/index.js diff --git a/web_services/foxx/brightid/initdb.js b/web_services/foxx/apply6/initdb.js similarity index 100% rename from web_services/foxx/brightid/initdb.js rename to web_services/foxx/apply6/initdb.js diff --git a/web_services/foxx/brightid/manifest_apply.json b/web_services/foxx/apply6/manifest.json similarity index 100% rename from web_services/foxx/brightid/manifest_apply.json rename to web_services/foxx/apply6/manifest.json diff --git a/web_services/foxx/apply6/manifest_apply.json b/web_services/foxx/apply6/manifest_apply.json new file mode 100644 index 00000000..3d75f9c1 --- /dev/null +++ b/web_services/foxx/apply6/manifest_apply.json @@ -0,0 +1,9 @@ +{ + "main": "apply.js", + "name": "apply", + "description": "Allows BrightID consensus module to apply operations to the database.", + "version": "6.18.0", + "scripts": { + "setup": "initdb.js" + } +} diff --git a/web_services/foxx/brightid/operations.js b/web_services/foxx/apply6/operations.js similarity index 100% rename from web_services/foxx/brightid/operations.js rename to web_services/foxx/apply6/operations.js diff --git a/web_services/foxx/brightid/package-lock.json b/web_services/foxx/apply6/package-lock.json similarity index 72% rename from web_services/foxx/brightid/package-lock.json rename to web_services/foxx/apply6/package-lock.json index d50aafb9..0eb8db36 100644 --- a/web_services/foxx/brightid/package-lock.json +++ b/web_services/foxx/apply6/package-lock.json @@ -1,42 +1,73 @@ { "name": "brightid-foxx", "version": "6.18.0", - "lockfileVersion": 1, + "lockfileVersion": 3, "requires": true, - "dependencies": { - "asn1": { + "packages": { + "": { + "name": "brightid-foxx", + "version": "6.18.0", + "dependencies": { + "base64-js": "^1.3.0", + "crypto-js": "^3.1.9-1", + "expr-eval": "^2.0.2", + "fast-json-stable-stringify": "^2.1.0", + "js-sha256": "0.9.0", + "jsbn": "1.1.0", + "keccak": "^3.0.0", + "lodash": "^4.17.11", + "node-rsa": "1.0.8", + "secp256k1": "^4.0.0", + "tweetnacl": "^1.0.0" + } + }, + "node_modules/asn1": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", - "requires": { + "dependencies": { "safer-buffer": "~2.1.0" } }, - "base64-js": { + "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" - }, - "bn.js": { + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/bn.js": { "version": "4.12.0", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" }, - "brorand": { + "node_modules/brorand": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=" }, - "crypto-js": { + "node_modules/crypto-js": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.3.0.tgz", "integrity": "sha512-DIT51nX0dCfKltpRiXV+/TVZq+Qq2NgF4644+K7Ttnla7zEzqc+kjJyiB96BHNyUTBxyjzRcZYpUdZa+QAqi6Q==" }, - "elliptic": { + "node_modules/elliptic": { "version": "6.5.4", "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==", - "requires": { + "dependencies": { "bn.js": "^4.11.9", "brorand": "^1.1.0", "hash.js": "^1.0.0", @@ -46,108 +77,121 @@ "minimalistic-crypto-utils": "^1.0.1" } }, - "expr-eval": { + "node_modules/expr-eval": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/expr-eval/-/expr-eval-2.0.2.tgz", "integrity": "sha512-4EMSHGOPSwAfBiibw3ndnP0AvjDWLsMvGOvWEZ2F96IGk0bIVdjQisOHxReSkE13mHcfbuCiXw+G4y0zv6N8Eg==" }, - "fast-json-stable-stringify": { + "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, - "hash.js": { + "node_modules/hash.js": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", - "requires": { + "dependencies": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" } }, - "hmac-drbg": { + "node_modules/hmac-drbg": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=", - "requires": { + "dependencies": { "hash.js": "^1.0.3", "minimalistic-assert": "^1.0.0", "minimalistic-crypto-utils": "^1.0.1" } }, - "inherits": { + "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, - "js-sha256": { + "node_modules/js-sha256": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.9.0.tgz", "integrity": "sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==" }, - "jsbn": { + "node_modules/jsbn": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", "integrity": "sha1-sBMHyym2GKHtJux56RH4A8TaAEA=" }, - "keccak": { + "node_modules/keccak": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/keccak/-/keccak-3.0.1.tgz", "integrity": "sha512-epq90L9jlFWCW7+pQa6JOnKn2Xgl2mtI664seYR6MHskvI9agt7AnDqmAlp9TqU4/caMYbA08Hi5DMZAl5zdkA==", - "requires": { + "hasInstallScript": true, + "dependencies": { "node-addon-api": "^2.0.0", "node-gyp-build": "^4.2.0" + }, + "engines": { + "node": ">=10.0.0" } }, - "lodash": { + "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, - "minimalistic-assert": { + "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" }, - "minimalistic-crypto-utils": { + "node_modules/minimalistic-crypto-utils": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=" }, - "node-addon-api": { + "node_modules/node-addon-api": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.2.tgz", "integrity": "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==" }, - "node-gyp-build": { + "node_modules/node-gyp-build": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.2.3.tgz", - "integrity": "sha512-MN6ZpzmfNCRM+3t57PTJHgHyw/h4OWnZ6mR8P5j/uZtqQr46RRuDE/P+g3n0YR/AiYXeWixZZzaip77gdICfRg==" + "integrity": "sha512-MN6ZpzmfNCRM+3t57PTJHgHyw/h4OWnZ6mR8P5j/uZtqQr46RRuDE/P+g3n0YR/AiYXeWixZZzaip77gdICfRg==", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } }, - "node-rsa": { + "node_modules/node-rsa": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-1.0.8.tgz", "integrity": "sha512-q8knkMHEqViIX/fshOltCHTtlt4Nw5wpBpu0//LB1tkxqYZB/001dYMwbPvTPiENwKvPqVDkhxK6J4fV09oa7w==", - "requires": { + "dependencies": { "asn1": "^0.2.4" } }, - "safer-buffer": { + "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, - "secp256k1": { + "node_modules/secp256k1": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-4.0.2.tgz", "integrity": "sha512-UDar4sKvWAksIlfX3xIaQReADn+WFnHvbVujpcbr+9Sf/69odMwy2MUsz5CKLQgX9nsIyrjuxL2imVyoNHa3fg==", - "requires": { + "hasInstallScript": true, + "dependencies": { "elliptic": "^6.5.2", "node-addon-api": "^2.0.0", "node-gyp-build": "^4.2.0" + }, + "engines": { + "node": ">=10.0.0" } }, - "tweetnacl": { + "node_modules/tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" diff --git a/web_services/foxx/brightid/package.json b/web_services/foxx/apply6/package.json similarity index 100% rename from web_services/foxx/brightid/package.json rename to web_services/foxx/apply6/package.json diff --git a/web_services/foxx/brightid/rsablind.js b/web_services/foxx/apply6/rsablind.js similarity index 100% rename from web_services/foxx/brightid/rsablind.js rename to web_services/foxx/apply6/rsablind.js diff --git a/web_services/foxx/brightid/schemas.js b/web_services/foxx/apply6/schemas.js similarity index 100% rename from web_services/foxx/brightid/schemas.js rename to web_services/foxx/apply6/schemas.js diff --git a/web_services/foxx/brightid/tests/connections.js b/web_services/foxx/apply6/tests/connections.js similarity index 100% rename from web_services/foxx/brightid/tests/connections.js rename to web_services/foxx/apply6/tests/connections.js diff --git a/web_services/foxx/apply6/tests/encoding.js b/web_services/foxx/apply6/tests/encoding.js new file mode 100644 index 00000000..7a63e203 --- /dev/null +++ b/web_services/foxx/apply6/tests/encoding.js @@ -0,0 +1,81 @@ +"use strict"; + +const enc = require("../encoding.js"); +const nacl = require("tweetnacl"); +const chai = require("chai"); +const should = chai.should(); +const expect = chai.expect; + +const b64ToUint8Array = enc.b64ToUint8Array; +const strToUint8Array = enc.strToUint8Array; + +describe("encoding", function () { + describe("Uint8Array", function () { + it(`should be defined`, function () { + expect(typeof Uint8Array).to.not.equal("undefined"); + }); + }); + + const s = "xyzABCDE"; + + describe(`The b64 string "${s}"`, function () { + describe("decoded as a Uint8Array", function () { + let e = ""; + try { + const array = Object.values(b64ToUint8Array(s)); + const expected_array = [199, 44, 192, 4, 32, 196]; + it(`should equal ${JSON.stringify(expected_array)}`, function () { + array.should.have.members(expected_array); + }); + it("should be a Uint8Array", function () { + expect(array instanceof Array); + }); + } catch (err) { + e = err; + } + it("should succeed", function () { + e.should.not.be.an("error"); + }); + }); + }); + + let safeB64 = []; + let regularB64 = []; + + safeB64[0] = "test-"; + regularB64[0] = "test+==="; + safeB64[1] = "_test-"; + regularB64[1] = "/test+=="; + safeB64[2] = "__test-"; + regularB64[2] = "//test+="; + safeB64[3] = "___test-"; + regularB64[3] = "///test+"; + + for (let i = 0; i <= 3; ++i) { + describe(`The url-safe b64 string "${safeB64[i]}"`, function () { + it(`should convert to the b64 string "${regularB64[i]}"`, function () { + enc.urlSafeB64ToB64(safeB64[i]).should.equal(regularB64[i]); + }); + }); + describe(`The b64 string "${regularB64[i]}"`, function () { + it(`should convert to the url-safe b64 string "${safeB64[i]}"`, function () { + enc.b64ToUrlSafeB64(regularB64[i]).should.equal(safeB64[i]); + }); + }); + } + + describe(`A b64 encoded publicKey and sig`, function () { + it(`should be usable by tweetnacl`, function () { + const publicKey = "zcsKbTYYKYc31hj/FCAmJlsizz2gOJRk+oOQYgQIpUg="; + const message = strToUint8Array("message"); + const sig = + "W9tcoxwdXr4er5FxH7LONOYKYSm1+DstAuhMhhuJXpzdlir5vbuIdfTAQDEABoyBqGtwhyKsKkMmsz8aD/wACQ=="; + (() => + nacl.sign.detached.verify( + message, + b64ToUint8Array(sig), + b64ToUint8Array(publicKey) + )).should.not.throw(); + }); + }); +}); diff --git a/web_services/foxx/brightid/tests/errors.js b/web_services/foxx/apply6/tests/errors.js similarity index 100% rename from web_services/foxx/brightid/tests/errors.js rename to web_services/foxx/apply6/tests/errors.js diff --git a/web_services/foxx/brightid/tests/groups.js b/web_services/foxx/apply6/tests/groups.js similarity index 100% rename from web_services/foxx/brightid/tests/groups.js rename to web_services/foxx/apply6/tests/groups.js diff --git a/web_services/foxx/brightid/tests/operations.js b/web_services/foxx/apply6/tests/operations.js similarity index 100% rename from web_services/foxx/brightid/tests/operations.js rename to web_services/foxx/apply6/tests/operations.js diff --git a/web_services/foxx/brightid/tests/replay_attack.js b/web_services/foxx/apply6/tests/replay_attack.js similarity index 100% rename from web_services/foxx/brightid/tests/replay_attack.js rename to web_services/foxx/apply6/tests/replay_attack.js diff --git a/web_services/foxx/brightid/tests/sponsorship.js b/web_services/foxx/apply6/tests/sponsorship.js similarity index 100% rename from web_services/foxx/brightid/tests/sponsorship.js rename to web_services/foxx/apply6/tests/sponsorship.js diff --git a/web_services/foxx/brightid/tests/time_window.js b/web_services/foxx/apply6/tests/time_window.js similarity index 100% rename from web_services/foxx/brightid/tests/time_window.js rename to web_services/foxx/apply6/tests/time_window.js diff --git a/web_services/foxx/brightid/tests/verifications.js b/web_services/foxx/apply6/tests/verifications.js similarity index 100% rename from web_services/foxx/brightid/tests/verifications.js rename to web_services/foxx/apply6/tests/verifications.js diff --git a/web_services/foxx/brightid5.zip b/web_services/foxx/brightid5.zip deleted file mode 100644 index 86e81061..00000000 Binary files a/web_services/foxx/brightid5.zip and /dev/null differ diff --git a/web_services/foxx/brightid5/apply.js b/web_services/foxx/brightid5/apply.js new file mode 100755 index 00000000..281a8508 --- /dev/null +++ b/web_services/foxx/brightid5/apply.js @@ -0,0 +1,108 @@ +"use strict"; +const createRouter = require("@arangodb/foxx/router"); +const joi = require("joi"); +const { db: arango, ArangoError } = require("@arangodb"); +const nacl = require("tweetnacl"); +const db = require("./db"); +const operations = require("./operations"); +const schemas = require("./schemas"); +const errors = require("./errors"); + +const router = createRouter(); +module.context.use(router); +const operationsHashesColl = arango._collection("operationsHashes"); + +const handlers = { + operationsPut: function (req, res) { + const op = req.body; + const hash = req.param("hash"); + op.hash = hash; + try { + // decrypt first to use the original hash for querying operationsHashesColl + if (op.name == "Link ContextId") { + operations.decrypt(op); + } + if (operationsHashesColl.exists(op.hash)) { + throw new errors.OperationAppliedBeforeError(op.hash); + } + operations.verify(op); + op.result = operations.apply(op); + op.state = "applied"; + operationsHashesColl.insert({ _key: op.hash }); + if (op.name == "Link ContextId") { + operations.encrypt(op); + } + } catch (e) { + op.state = "failed"; + if (e instanceof ArangoError) { + e.arangoErrorNum = e.errorNum; + e.errorNum = errors.ARANGO_ERROR; + } + op.result = { + message: e.message || e, + stack: !(e instanceof errors.BrightIDError) ? e.stack : undefined, + errorNum: e.errorNum, + arangoErrorNum: e.arangoErrorNum, + }; + } + db.upsertOperation(op); + res.send({ success: true, state: op.state, result: op.result }); + }, +}; + +// add blockTime to operation schema +schemas.schemas.operation = joi + .alternatives() + .try( + Object.values(schemas.operations).map((op) => { + op.blockTime = joi + .number() + .required() + .description("milliseconds since epoch when the block was created"); + return joi.object(op); + }) + ) + .description( + "Send operations to idchain to be applied to BrightID nodes' databases after consensus" + ); + +router + .put("/operations/:hash", handlers.operationsPut) + .pathParam( + "hash", + joi.string().required().description("sha256 hash of the operation message") + ) + .body(schemas.schemas.operation) + .summary("Apply operation after consensus") + .description("Apply operation after consensus.") + .response(null); + +module.context.use(function (req, res, next) { + try { + next(); + } catch (e) { + if (e.cause && e.cause.isJoi) { + e.code = 400; + if ( + req._raw.url.includes("operations") && + e.cause.details && + e.cause.details.length > 0 + ) { + let msg1 = ""; + const msg2 = "invalid operation name"; + e.cause.details.forEach((d) => { + if (!d.message.includes('"name" must be one of')) { + msg1 += `${d.message}, `; + } + }); + e.message = msg1 || msg2; + } + } + console.group("Error returned"); + console.log("url:", req._raw.requestType, req._raw.url); + console.log("error:", e); + console.log("body:", req.body); + console.groupEnd(); + res.throw(e.code || 500, e); + } +}); diff --git a/web_services/foxx/brightid5/db.js b/web_services/foxx/brightid5/db.js new file mode 100755 index 00000000..9b5ccd31 --- /dev/null +++ b/web_services/foxx/brightid5/db.js @@ -0,0 +1,1244 @@ +"use strict"; +const { sha256 } = require("@arangodb/crypto"); +const { query, db, aql } = require("@arangodb"); +const _ = require("lodash"); +const stringify = require("fast-json-stable-stringify"); +const nacl = require("tweetnacl"); +const { + urlSafeB64ToB64, + priv2addr, + getNaclKeyPair, + getEthKeyPair, + getConsensusSenderAddress, + recoverEthSigner, +} = require("./encoding"); +const parser = require("expr-eval").Parser; +const errors = require("./errors"); + +const connectionsColl = db._collection("connections"); +const connectionsHistoryColl = db._collection("connectionsHistory"); +const groupsColl = db._collection("groups"); +const usersInGroupsColl = db._collection("usersInGroups"); +const usersColl = db._collection("users"); +const contextsColl = db._collection("contexts"); +const appsColl = db._collection("apps"); +const sponsorshipsColl = db._collection("sponsorships"); +const operationsColl = db._collection("operations"); +const invitationsColl = db._collection("invitations"); +const verificationsColl = db._collection("verifications"); +const variablesColl = db._collection("variables"); +const testblocksColl = db._collection("testblocks"); + +function addConnection(key1, key2, timestamp) { + // this function is deprecated and will be removed on v6 + connect({ id1: key1, id2: key2, timestamp }); + connect({ id1: key2, id2: key1, timestamp }); +} + +function connect(op) { + let { + id1: key1, + id2: key2, + level, + reportReason, + replacedWith, + requestProof, + timestamp, + } = op; + + const _from = "users/" + key1; + const _to = "users/" + key2; + if (level == "recovery") { + const tf = connectionsColl.firstExample({ _from: _to, _to: _from }); + if (!tf || !["already known", "recovery"].includes(tf.level)) { + throw new errors.IneligibleRecoveryConnection(); + } + } + + // create user by adding connection if it's not created + // todo: we should prevent non-verified users from creating new users by making connections. + let u1 = loadUser(key1); + let u2 = loadUser(key2); + if (!u1) { + u1 = createUser(key1, timestamp); + } + if (!u2) { + u2 = createUser(key2, timestamp); + } + + // set the first verified user that connect to a user as its parent + let verifications = userVerifications(key1); + if (!u2.parent && verifications.map((v) => v.name).includes("BrightID")) { + usersColl.update(u2, { parent: key1 }); + } + + const conn = connectionsColl.firstExample({ _from, _to }); + + if (level != "reported") { + // clear reportReason for levels other than reported + reportReason = null; + } + if (level != "reported" || reportReason != "replaced") { + // clear replacedWith for levels other than reported + // and reportReason other than replaced + replacedWith = null; + } + if (replacedWith && !loadUser(replacedWith)) { + throw new errors.UserNotFoundError(replacedWith); + } + if (!level) { + // Set 'just met' as confidence level when old addConnection is called + // and there was no other level set directly using Connect + // this if should be removed when v5 dropped and "Add Connection" operation removed + level = conn ? conn.level : "just met"; + } + + connectionsHistoryColl.insert({ + _from, + _to, + level, + reportReason, + replacedWith, + requestProof, + timestamp, + }); + + if (!conn) { + connectionsColl.insert({ + _from, + _to, + level, + reportReason, + replacedWith, + requestProof, + timestamp, + initTimestamp: timestamp, + }); + } else { + connectionsColl.update(conn, { + level, + reportReason, + replacedWith, + requestProof, + timestamp, + }); + } +} + +function removeConnection(reporter, reported, reportReason, timestamp) { + // this function is deprecated and will be removed on v6 + connect({ + id1: reporter, + id2: reported, + level: "reported", + reportReason, + timestamp, + }); +} + +function userConnections(userId, direction = "outbound") { + let query, resIdAttr; + if (direction == "outbound") { + query = { _from: "users/" + userId }; + resIdAttr = "_to"; + } else { + query = { _to: "users/" + userId }; + resIdAttr = "_from"; + } + return connectionsColl + .byExample(query) + .toArray() + .map((conn) => { + return { + id: conn[resIdAttr].replace("users/", ""), + level: conn.level, + reportReason: conn.reportReason || undefined, + timestamp: conn.timestamp, + }; + }); +} + +function userToDic(userId) { + const u = usersColl.document("users/" + userId); + return { + id: u._key, + // all signing keys will be returned on v6 + signingKey: u.signingKeys[0], + // score is deprecated and will be removed on v6 + score: u.score, + verifications: userVerifications(u._key).map((v) => v.name), + hasPrimaryGroup: hasPrimaryGroup(u._key), + // trusted is deprecated and will be replaced by recoveryConnections on v6 + trusted: getRecoveryConnections(u._key), + // flaggers is deprecated and will be replaced by reporters on v6 + flaggers: getReporters(u._key), + createdAt: u.createdAt, + // eligible_groups is deprecated and will be removed on v6 + eligible_groups: u.eligible_groups || [], + }; +} + +function getReporters(user) { + const reporters = {}; + connectionsColl + .byExample({ + _to: "users/" + user, + level: "reported", + }) + .toArray() + .forEach((c) => { + reporters[c._from.replace("users/", "")] = c.reportReason; + }); + return reporters; +} + +function groupMembers(groupId) { + return usersInGroupsColl + .byExample({ + _to: "groups/" + groupId, + }) + .toArray() + .map((e) => e._from.replace("users/", "")); +} + +// this function is deprecated and will be removed on v6 +function updateEligibleGroups(userId, connections, currentGroups) { + connections = connections.map((uId) => "users/" + uId); + currentGroups = currentGroups.map((gId) => "groups/" + gId); + const user = "users/" + userId; + const candidates = query` + FOR edge in ${usersInGroupsColl} + FILTER edge._from in ${connections} + FILTER edge._to NOT IN ${currentGroups} + COLLECT group=edge._to WITH COUNT INTO count + SORT count DESC + RETURN { + group, + count + } + `.toArray(); + const groupIds = candidates.map((x) => x.group); + const groupCounts = query` + FOR ug in ${usersInGroupsColl} + FILTER ug._to in ${groupIds} + COLLECT id=ug._to WITH COUNT INTO count + return { + id, + count + } + `.toArray(); + + const groupCountsDic = {}; + + groupCounts.map(function (row) { + groupCountsDic[row.id] = row.count; + }); + + const eligible_groups = candidates + .filter((g) => g.count * 2 >= groupCountsDic[g.group]) + .map((g) => g.group.replace("groups/", "")); + usersColl.update(userId, { + eligible_groups, + eligible_timestamp: Date.now(), + }); + return eligible_groups; +} + +// this function is deprecated and will be removed on v6 +function updateEligibles(groupId) { + const members = groupMembers(groupId); + const neighbors = []; + const isKnown = (c) => + ["just met", "already known", "recovery"].includes(c.level); + + members.forEach((member) => { + const conns = connectionsColl + .byExample({ + _from: "users/" + member, + }) + .toArray() + .filter(isKnown) + .map((c) => c._to.replace("users/", "")) + .filter((u) => !members.includes(u)); + neighbors.push(...conns); + }); + + const counts = {}; + for (let neighbor of neighbors) { + counts[neighbor] = (counts[neighbor] || 0) + 1; + } + const eligibles = Object.keys(counts).filter((neighbor) => { + return counts[neighbor] >= members.length / 2; + }); + // storing eligible groups on users documents and updating them + // from this route will be removed when clients updated to use + // new GET /groups/{id} result to show eligibles in invite list + eligibles.forEach((neighbor) => { + let { eligible_groups } = usersColl.document(neighbor); + eligible_groups = eligible_groups || []; + if (eligible_groups.indexOf(groupId) == -1) { + eligible_groups.push(groupId); + usersColl.update(neighbor, { + eligible_groups, + }); + } + }); + return eligibles; +} + +function groupToDic(groupId) { + const group = groupsColl.document("groups/" + groupId); + return { + id: group._key, + members: groupMembers(group._key), + type: group.type || "general", + founders: (group.founders || []).map((founder) => + founder.replace("users/", "") + ), + admins: group.admins || group.founders, + isNew: group.isNew, + // score on group is deprecated and will be removed on v6 + score: 0, + url: group.url, + timestamp: group.timestamp, + }; +} + +function userGroups(userId) { + return usersInGroupsColl + .byExample({ + _from: "users/" + userId, + }) + .toArray() + .map((ug) => { + return { + id: ug._to.replace("groups/", ""), + timestamp: ug.timestamp, + }; + }); +} + +function userInvitedGroups(userId) { + return invitationsColl + .byExample({ + _from: "users/" + userId, + }) + .toArray() + .filter((invite) => { + return Date.now() - invite.timestamp < 86400000; + }) + .map((invite) => { + let group = groupToDic(invite._to.replace("groups/", "")); + group.inviter = invite.inviter; + group.inviteId = invite._key; + group.data = invite.data; + group.invited = invite.timestamp; + return group; + }); +} + +function invite(inviter, invitee, groupId, data, timestamp) { + if (!groupsColl.exists(groupId)) { + throw new errors.GroupNotFoundError(groupId); + } + const group = groupsColl.document(groupId); + if (!group.admins || !group.admins.includes(inviter)) { + throw new errors.NotAdminError(); + } + if (group.type == "primary" && hasPrimaryGroup(invitee)) { + throw new errors.AlreadyHasPrimaryGroupError(); + } + if (group.isNew && !group.founders.includes(invitee)) { + throw new errors.NewUserBeforeFoundersJoinError(); + } + invitationsColl.removeByExample({ + _from: "users/" + invitee, + _to: "groups/" + groupId, + }); + invitationsColl.insert({ + _from: "users/" + invitee, + _to: "groups/" + groupId, + inviter, + data, + timestamp, + }); +} + +function dismiss(dismisser, dismissee, groupId, timestamp) { + if (!groupsColl.exists(groupId)) { + throw new errors.GroupNotFoundError(groupId); + } + const group = groupsColl.document(groupId); + if (!group.admins || !group.admins.includes(dismisser)) { + throw new errors.NotAdminError(); + } + deleteMembership(groupId, dismissee, timestamp); +} + +function loadUser(id) { + return query`RETURN DOCUMENT(${usersColl}, ${id})`.toArray()[0]; +} + +function userScore(key) { + return query` + FOR u in ${usersColl} + FILTER u._key == ${key} + RETURN u.score + `.toArray()[0]; +} + +function createUser(key, timestamp) { + // already exists? + const user = loadUser(key); + + if (!user) { + return usersColl.insert({ + score: 0, + signingKeys: [urlSafeB64ToB64(key)], + createdAt: timestamp, + _key: key, + }); + } else { + return user; + } +} + +function hasPrimaryGroup(key) { + const groupIds = usersInGroupsColl + .byExample({ + _from: "users/" + key, + }) + .toArray() + .map((ug) => ug._to.replace("groups/", "")); + const groups = groupsColl.documents(groupIds).documents; + return groups.filter((group) => group.type == "primary").length > 0; +} + +function createGroup( + groupId, + key1, + key2, + inviteData2, + key3, + inviteData3, + url, + type, + timestamp +) { + if (!["general", "primary"].includes(type)) { + throw new errors.InvalidGroupTypeError(type); + } + + if (groupsColl.exists(groupId)) { + throw new errors.DuplicateGroupError(); + } + + const conns = connectionsColl + .byExample({ + _to: "users/" + key1, + }) + .toArray() + .map((u) => u._from.replace("users/", "")); + if (conns.indexOf(key2) < 0 || conns.indexOf(key3) < 0) { + throw new errors.InvalidCoFoundersError(); + } + + const founders = [key1, key2, key3].sort(); + if (type == "primary" && founders.some(hasPrimaryGroup)) { + throw new errors.AlreadyHasPrimaryGroupError(); + } + + groupsColl.insert({ + _key: groupId, + score: 0, + isNew: true, + admins: founders, + url, + type, + timestamp, + founders, + }); + + // Add the creator and invite other cofounders to the group now. + // The other two "co-founders" have to join using /membership + addUserToGroup(groupId, key1, timestamp); + invite(key1, key2, groupId, inviteData2, timestamp); + invite(key1, key3, groupId, inviteData3, timestamp); +} + +function addAdmin(key, admin, groupId) { + if (!groupsColl.exists(groupId)) { + throw new errors.GroupNotFoundError(groupId); + } + if ( + !usersInGroupsColl.firstExample({ + _from: "users/" + admin, + _to: "groups/" + groupId, + }) + ) { + throw new errors.IneligibleNewAdminError(); + } + const group = groupsColl.document(groupId); + if (!group.admins || !group.admins.includes(key)) { + throw new errors.NotAdminError(); + } + group.admins.push(admin); + groupsColl.update(group, { admins: group.admins }); +} + +function addUserToGroup(groupId, key, timestamp) { + const user = "users/" + key; + const group = "groups/" + groupId; + + const edge = usersInGroupsColl.firstExample({ + _from: user, + _to: group, + }); + if (!edge) { + usersInGroupsColl.insert({ + _from: user, + _to: group, + timestamp, + }); + } else { + usersInGroupsColl.update(edge, { timestamp }); + } +} + +function addMembership(groupId, key, timestamp) { + if (!groupsColl.exists(groupId)) { + throw new errors.GroupNotFoundError(groupId); + } + + const group = groupsColl.document(groupId); + if (group.isNew && !group.founders.includes(key)) { + throw new errors.NewUserBeforeFoundersJoinError(); + } + + if (group.type == "primary" && hasPrimaryGroup(key)) { + throw new errors.AlreadyHasPrimaryGroupError(); + } + + const invite = invitationsColl.firstExample({ + _from: "users/" + key, + _to: "groups/" + groupId, + }); + // invites will expire after 24 hours + if (!invite || timestamp - invite.timestamp >= 86400000) { + throw new errors.NotInvitedError(); + } + // remove invite after joining to not allow reusing that + invitationsColl.remove(invite); + + addUserToGroup(groupId, key, timestamp); + + if (groupMembers(groupId).length == group.founders.length) { + groupsColl.update(group, { isNew: false }); + } + updateEligibles(groupId); +} + +function deleteGroup(groupId, key, timestamp) { + if (!groupsColl.exists(groupId)) { + throw new errors.GroupNotFoundError(groupId); + } + + const group = groupsColl.document(groupId); + if (group.admins.indexOf(key) < 0) { + throw new errors.NotAdminError(); + } + + invitationsColl.removeByExample({ _to: "groups/" + groupId }); + usersInGroupsColl.removeByExample({ _to: "groups/" + groupId }); + groupsColl.remove(group); +} + +function deleteMembership(groupId, key, timestamp) { + if (!groupsColl.exists(groupId)) { + throw new errors.GroupNotFoundError(groupId); + } + const group = groupsColl.document(groupId); + if (group.admins && group.admins.includes(key)) { + const admins = group.admins.filter((admin) => key != admin); + const members = groupMembers(groupId); + if (admins.length == 0 && members.length > 1) { + throw new errors.LeaveGroupError(); + } + groupsColl.update(group, { admins }); + } + usersInGroupsColl.removeByExample({ + _from: "users/" + key, + _to: "groups/" + groupId, + }); +} + +function getContext(context) { + if (!contextsColl.exists(context)) { + throw new errors.ContextNotFoundError(context); + } + return contextsColl.document(context); +} + +function getApp(app) { + if (!appsColl.exists(app)) { + throw new errors.AppNotFoundError(app); + } + return appsColl.document(app); +} + +function getApps() { + return appsColl.all().toArray(); +} + +function appToDic(app) { + return { + id: app._key, + name: app.name, + context: app.context, + verification: app.verification, + verificationUrl: app.verificationUrl, + logo: app.logo, + url: app.url, + assignedSponsorships: app.totalSponsorships, + unusedSponsorships: app.totalSponsorships - (app.usedSponsorships || 0), + testing: app.testing, + soulbound: app.soulbound, + soulboundMessage: + app.context && contextsColl.exists(app.context) + ? contextsColl.document(app.context).soulboundMessage || "" + : "", + }; +} + +function getUserByContextId(coll, contextId) { + return query` + FOR l in ${coll} + FILTER l.contextId == ${contextId} + RETURN l.user + `.toArray()[0]; +} + +function getContextIdsByUser(coll, id) { + return query` + FOR u in ${coll} + FILTER u.user == ${id} + SORT u.timestamp DESC + RETURN u.contextId + `.toArray(); +} + +function getLastContextIds(appKey, countOnly) { + const { context, verification } = getApp(appKey); + const { collection } = getContext(context); + const coll = db._collection(collection); + + const baseQuery = aql` + FOR c IN ${coll} + FOR s IN ${sponsorshipsColl} + FILTER s._from == CONCAT("users/", c.user) OR (s.appId == c.contextId AND s.appHasAuthorized AND s.spendRequested) + FOR v in verifications + FILTER v.user == c.user AND v.expression == true AND v.name == ${verification} + `; + const data = {}; + if (countOnly) { + data["count"] = db + ._query( + aql` + ${baseQuery} + COLLECT user = c.user + COLLECT WITH COUNT INTO length + RETURN length + ` + ) + .toArray()[0]; + } else { + data["contextIds"] = db + ._query( + aql` + ${baseQuery} + SORT c.timestamp DESC + COLLECT user = c.user INTO contextIds = c.contextId + RETURN contextIds[0] + ` + ) + .toArray(); + data["count"] = data["contextIds"].length; + } + return data; +} + +function userVerifications(user) { + let hashes = variablesColl.document("VERIFICATIONS_HASHES").hashes; + hashes = JSON.parse(hashes); + // const snapshotPeriod = hashes[1]['block'] - hashes[0]['block'] + // const lastBlock = variablesColl.document('LAST_BLOCK').value; + // // We want verifications from the second-most recently generated snapshot + // // prior to LAST_BLOCK. We use this approach to ensure all synced nodes return + // // verifications from same block regardless of how fast they are in processing + // // new generated snapshots and adding new verifications to database. + // let block; + // if (lastBlock > hashes[1]['block'] + snapshotPeriod) { + // block = hashes[1]['block']; + // } else { + // block = hashes[0]['block']; + // } + + // rollback consneus based block selection consneus temporarily to ensure faster verification + let block = Math.max(...Object.keys(hashes)); + + const verifications = verificationsColl.byExample({ user, block }).toArray(); + verifications.forEach((v) => { + delete v._key; + delete v._id; + delete v._rev; + delete v.user; + }); + + // replace expression based verification with app based ones + getApps().forEach((app) => { + const v = verifications.find( + (v) => v.expression && app.verification == v.name + ); + if (v) { + verifications.push({ + app: true, + name: app._key, + timestamp: v.timestamp, + block: v.block, + }); + } + }); + return verifications.filter((v) => !v.expression); +} + +function linkContextId(id, context, contextId, timestamp) { + const { collection, idsAsHex, soulbound, soulboundMessage } = + getContext(context); + + if (!loadUser(id)) { + throw new errors.UserNotFoundError(id); + } + + if (!contextId) { + throw new errors.InvalidContextIdError(contextId); + } + + if (soulboundMessage) { + if (!isEthereumSignature(contextId)) { + throw new errors.InvalidContextIdError(contextId); + } + contextId = recoverEthSigner(contextId, soulboundMessage); + } else if (idsAsHex) { + if (!isEthereumAddress(contextId)) { + throw new errors.InvalidContextIdError(contextId); + } + contextId = contextId.toLowerCase(); + } else if (soulbound) { + if (contextId.length > 32) { + throw new errors.InvalidContextIdError(contextId); + } + } + + // remove testblocks if exists + removeTestblock(contextId, "link"); + + const coll = db._collection(collection); + let user = getUserByContextId(coll, contextId); + if (user && user != id) { + throw new errors.DuplicateContextIdError(contextId); + } + + const appsWithSameContext = query` + FOR app in apps + FILTER app.context == ${context} + return app + `.toArray(); + let verified = false; + for (let app of appsWithSameContext) { + if (isVerifiedFor(id, app.verification)) { + verified = true; + break; + } + } + if (!verified) { + throw new errors.NotVerifiedError(context); + } + + const links = coll.byExample({ user: id }).toArray(); + const recentLinks = links.filter( + (link) => timestamp - link.timestamp < 24 * 3600 * 1000 + ); + if (recentLinks.length >= 3) { + throw new errors.TooManyLinkRequestError(); + } + + // accept link if the contextId is used by the same user before + for (let link of links) { + if (link.contextId === contextId) { + if (timestamp > link.timestamp) { + coll.update(link, { timestamp }); + } + return; + } + } + + coll.insert({ + user: id, + contextId, + timestamp, + }); +} + +function setRecoveryConnections(conns, key, timestamp) { + // this function is deprecated and will be removed on v6 + conns.forEach((conn) => { + connect({ + id1: key, + id2: conn, + level: "recovery", + timestamp, + }); + }); +} + +function getRecoveryPeriods(allConnections, user, now) { + const recoveryPeriods = []; + const history = allConnections.filter((c) => c.id == user); + let open = false; + let period = {}; + for (let i = 0; i < history.length; i++) { + if (history[i].level == "recovery" && !open) { + open = true; + period["start"] = history[i].timestamp; + } else if (history[i].level != "recovery" && open) { + period["end"] = history[i].timestamp; + recoveryPeriods.push(period); + period = {}; + open = false; + } + } + if (open) { + period["end"] = now; + recoveryPeriods.push(period); + } + return recoveryPeriods; +} + +function getRecoveryConnections(user) { + const res = []; + const allConnections = connectionsHistoryColl + .byExample({ + _from: "users/" + user, + }) + .toArray() + .map((c) => { + return { + id: c._to.replace("users/", ""), + level: c.level, + timestamp: c.timestamp, + }; + }); + allConnections.sort((c1, c2) => c1.timestamp - c2.timestamp); + const recoveryConnections = allConnections.filter( + (conn) => conn.level == "recovery" + ); + if (recoveryConnections.length == 0) { + return res; + } + + const now = Date.now(); + const firstDayBorder = recoveryConnections[0].timestamp + 24 * 60 * 60 * 1000; + const aWeek = 7 * 24 * 60 * 60 * 1000; + const aWeekBorder = Date.now() - aWeek; + const recoveryIds = new Set(recoveryConnections.map((conn) => conn.id)); + + // 1) New recovery connections can participate in resetting signing key, + // one week after being set as recovery connection. This limit is not + // applied to recovery connections that users set for the first time. + // 2) Removed recovery connections can continue participating in resetting + // signing key, for one week after being removed from recovery connections + for (let id of recoveryIds) { + // find the periods that this user was recovery + const recoveryPeriods = getRecoveryPeriods(allConnections, id, now); + // find this user is recovery now + for (const period of recoveryPeriods) { + if ( + period.end > aWeekBorder && + (period.end - period.start > aWeek || period.start < firstDayBorder) + ) { + res.push(id); + } + } + } + return res; +} + +function setSigningKey(signingKey, key, timestamp) { + usersColl.update(key, { + signingKeys: [signingKey], + updateTime: timestamp, + }); + + // remove pending invites, because they can not be decrypted anymore by the new signing key + invitationsColl.removeByExample({ + _from: "users/" + key, + }); +} + +function isSponsored(key) { + return sponsorshipsColl.firstExample({ _from: "users/" + key }) != null; +} + +function getSponsorship(contextId) { + const sponsorship = sponsorshipsColl.firstExample({ appId: contextId }); + if (!sponsorship) { + throw new errors.NotSponsoredError(contextId); + } + return sponsorship; +} + +function sponsor(op) { + const app = appsColl.document(op.app); + + if (op.id) { + //check app verifications and user verifications + if (!isVerifiedFor(op.id, app.verification)) { + throw new errors.NotVerifiedError(app.context, app._key); + } + const sponsorship = sponsorshipsColl.firstExample({ + _to: `apps/${op.app}`, + _from: `users/${op.id}`, + }); + if (sponsorship) { + throw new errors.SponsoredBeforeError(); + } + + sponsorshipsColl.insert({ + _from: `users/${op.id}`, + _to: "apps/" + op.app, + appHasAuthorized: true, + timestamp: op.timestamp, + appId: null + }); + appsColl.update(app, { usedSponsorships: (app.usedSponsorships || 0) + 1 }); + + //! TODO: deprecated + } + else { + if ( + op.name == "Sponsor" && + app.totalSponsorships - (app.usedSponsorships || 0) < 1 + ) { + throw new errors.UnusedSponsorshipsError(op.app); + } + + const contextId = app.idsAsHex ? op.contextId.toLowerCase() : op.contextId; + // remove testblocks if exists + removeTestblock(contextId, "sponsorship", op.app); + + const sponsorship = sponsorshipsColl.firstExample({ + appId: contextId, + _to: "apps/" + op.app, + }); + if (!sponsorship) { + sponsorshipsColl.insert({ + _from: "users/0", + _to: "apps/" + op.app, + expireDate: Math.ceil(Date.now() / 1000 + 60 * 60), + appId: contextId, + appHasAuthorized: op.name == "Sponsor", + spendRequested: op.name == "Spend Sponsorship", + timestamp: op.timestamp, + }); + return; + } + + if (sponsorship.appHasAuthorized && sponsorship.spendRequested) { + throw new errors.SponsoredBeforeError(); + } + + if (op.name == "Sponsor" && sponsorship.appHasAuthorized) { + throw new errors.AppAuthorizedBeforeError(); + } + + if (op.name == "Spend Sponsorship" && sponsorship.spendRequested) { + throw new errors.SpendRequestedBeforeError(); + } + + sponsorshipsColl.update(sponsorship, { + expireDate: null, + appHasAuthorized: true, + spendRequested: true, + timestamp: op.timestamp, + }); + + appsColl.update(app, { usedSponsorships: (app.usedSponsorships || 0) + 1 }); + } +} + +function loadOperation(key) { + return query`RETURN DOCUMENT(${operationsColl}, ${key})`.toArray()[0]; +} + +function upsertOperation(op) { + if (!operationsColl.exists(op.hash)) { + op._key = op.hash; + operationsColl.insert(op); + } else { + operationsColl.replace(op.hash, op); + } +} + +function getState() { + const lastProcessedBlock = variablesColl.document("LAST_BLOCK").value; + const verificationsBlock = variablesColl.document("VERIFICATION_BLOCK").value; + const initOp = query` + FOR o in ${operationsColl} + FILTER o.state == "init" + COLLECT WITH COUNT INTO length + RETURN length + `.toArray()[0]; + const sentOp = query` + FOR o in ${operationsColl} + FILTER o.state == "sent" + COLLECT WITH COUNT INTO length + RETURN length + `.toArray()[0]; + const verificationsHashes = JSON.parse( + variablesColl.document("VERIFICATIONS_HASHES").hashes + ); + const consensusSenderAddress = getConsensusSenderAddress(); + const { privateKey: ethPrivateKey } = getEthKeyPair(); + const { publicKey: naclSigningKey } = getNaclKeyPair(); + return { + lastProcessedBlock, + verificationsBlock, + initOp, + sentOp, + verificationsHashes, + ethSigningAddress: priv2addr(ethPrivateKey), + naclSigningKey, + consensusSenderAddress, + version: module.context.manifest.version, + }; +} + +function addTestblock(contextId, action, app) { + testblocksColl.insert({ app, contextId, action, timestamp: Date.now() }); +} + +function removeTestblock(contextId, action, app) { + let query; + if (app) { + query = { app, contextId, action }; + } else { + query = { contextId, action }; + } + testblocksColl.removeByExample(query); +} + +function getTestblocks(app, contextId) { + return testblocksColl + .byExample({ + app: app, + contextId: contextId, + }) + .toArray() + .map((b) => b.action); +} + +function getContextIds(coll) { + return coll + .all() + .toArray() + .map((c) => { + return { + user: c.user, + contextId: c.contextId, + timestamp: c.timestamp, + }; + }); +} + +function loadGroup(groupId) { + return query`RETURN DOCUMENT(${groupsColl}, ${groupId})`.toArray()[0]; +} + +function groupInvites(groupId) { + return invitationsColl + .byExample({ + _to: "groups/" + groupId, + }) + .toArray() + .filter((invite) => { + return Date.now() - invite.timestamp < 86400000; + }) + .map((invite) => { + return { + inviter: invite.inviter, + invitee: invite._from.replace("users/", ""), + id: invite._key, + data: invite.data, + timestamp: invite.timestamp, + }; + }); +} + +function removePasscode(contextKey) { + contextsColl.update(contextKey, { + passcode: null, + }); +} + +function updateGroup(admin, groupId, url, timestamp) { + if (!groupsColl.exists(groupId)) { + throw new errors.GroupNotFoundError(groupId); + } + const group = groupsColl.document(groupId); + if (!group.admins || !group.admins.includes(admin)) { + throw new errors.NotAdminError(); + } + groupsColl.update(group, { + url, + timestamp, + }); +} + +function addSigningKey(id, signingKey, timestamp) { + const signingKeys = usersColl.document(id).signingKeys || []; + if (signingKeys.indexOf(signingKey) == -1) { + signingKeys.push(signingKey); + usersColl.update(id, { signingKeys }); + } +} + +function removeSigningKey(id, signingKey) { + let signingKeys = usersColl.document(id).signingKeys || []; + signingKeys = signingKeys.filter((s) => s != signingKey); + usersColl.update(id, { signingKeys }); +} + +function removeAllSigningKeys(id, signingKey) { + let signingKeys = usersColl.document(id).signingKeys || []; + signingKeys = signingKeys.filter((s) => s == signingKey); + usersColl.update(id, { signingKeys }); +} + +function isEthereumAddress(address) { + const re = new RegExp(/^0[xX][A-Fa-f0-9]{40}$/); + return re.test(address); +} + +function isEthereumSignature(sig) { + const re = new RegExp(/^[A-Fa-f0-9]{130}$/); + return re.test(sig); +} + +function sponsorRequestedRecently(op) { + const lastSponsorTimestamp = query` + FOR o in ${operationsColl} + FILTER o.name == "Sponsor" + AND o.contextId IN ${[op.contextId, op.contextId.toLowerCase()]} + SORT o.timestamp ASC + RETURN o.timestamp + ` + .toArray() + .pop(); + + const timeWindow = module.context.configuration.operationsTimeWindow * 1000; + return lastSponsorTimestamp && Date.now() - lastSponsorTimestamp < timeWindow; +} + +function isSponsoredByContextId(op) { + const sponsored = query` + FOR s in ${sponsorshipsColl} + FILTER s._to == CONCAT("apps/", ${op.app}) + AND s.appHasAuthorized == true + AND s.spendRequested == true + AND s.appId IN ${[op.contextId, op.contextId.toLowerCase()]} + RETURN s.appId + ` + .toArray() + .pop(); + if (sponsored) { + return true; + } + + return false; +} + +function isVerifiedFor(user, verification) { + let verifications = userVerifications(user); + verifications = _.keyBy(verifications, (v) => v.name); + let verified; + try { + let expr = parser.parse(verification); + for (let v of expr.variables()) { + if (!verifications[v]) { + verifications[v] = false; + } + } + verified = expr.evaluate(verifications); + } catch (err) { + return false; + } + return verified; +} + +module.exports = { + connect, + addConnection, + removeConnection, + createGroup, + deleteGroup, + addAdmin, + addMembership, + deleteMembership, + updateEligibleGroups, + invite, + dismiss, + userConnections, + userGroups, + loadUser, + userInvitedGroups, + createUser, + groupMembers, + userScore, + getContext, + getApp, + getApps, + appToDic, + userVerifications, + getUserByContextId, + getContextIdsByUser, + sponsor, + isSponsored, + getSponsorship, + linkContextId, + loadOperation, + upsertOperation, + setRecoveryConnections, + setSigningKey, + getLastContextIds, + getState, + getReporters, + getRecoveryConnections, + userToDic, + groupToDic, + addTestblock, + removeTestblock, + getTestblocks, + addSigningKey, + removeSigningKey, + removeAllSigningKeys, + getContextIds, + removePasscode, + loadGroup, + groupInvites, + updateEligibles, + updateGroup, + isEthereumAddress, + sponsorRequestedRecently, + isSponsoredByContextId, + isVerifiedFor, +}; diff --git a/web_services/foxx/brightid5/encoding.js b/web_services/foxx/brightid5/encoding.js new file mode 100755 index 00000000..0d6ccd6c --- /dev/null +++ b/web_services/foxx/brightid5/encoding.js @@ -0,0 +1,161 @@ +"use strict"; +const B64 = require("base64-js"); +const crypto = require("@arangodb/crypto"); +const nacl = require("tweetnacl"); +const secp256k1 = require("secp256k1"); +const createKeccakHash = require("keccak"); + +const conf = module.context.configuration; + +function uInt8ArrayToB64(array) { + const b = Buffer.from(array); + return b.toString("base64"); +} + +function b64ToUint8Array(str) { + // B64.toByteArray might return a Uint8Array, an Array or an Object depending on the platform. + // Wrap it in Object.values and new Uint8Array to make sure it's a Uint8Array. + return new Uint8Array(Object.values(B64.toByteArray(str))); +} + +function strToUint8Array(str) { + return new Uint8Array(Buffer.from(str, "ascii")); +} + +function b64ToUrlSafeB64(s) { + const alts = { + "/": "_", + "+": "-", + "=": "", + }; + return s.replace(/[/+=]/g, (c) => alts[c]); +} + +function urlSafeB64ToB64(s) { + const alts = { + _: "/", + "-": "+", + }; + s = s.replace(/[-_]/g, (c) => alts[c]); + while (s.length % 4) { + s += "="; + } + return s; +} + +function hash(data) { + const h = crypto.sha256(data); + const b = Buffer.from(h, "hex").toString("base64"); + return b64ToUrlSafeB64(b); +} + +function pad32(data) { + return data + String.fromCharCode(0).repeat(32 - data.length); +} + +function addressToBytes32(address) { + const b = Buffer.from(address.substring(2), "hex").toString("binary"); + return String.fromCharCode(0).repeat(12) + b; +} + +function priv2addr(priv) { + if (!priv) { + return null; + } + const publicKey = Buffer.from( + Object.values(secp256k1.publicKeyCreate(priv, false).slice(1)) + ); + return ( + "0x" + + createKeccakHash("keccak256") + .update(publicKey) + .digest() + .slice(-20) + .toString("hex") + ); +} + +function getNaclKeyPair() { + let publicKey, privateKey; + if (conf.privateKey) { + publicKey = uInt8ArrayToB64( + Object.values( + nacl.sign.keyPair.fromSecretKey(b64ToUint8Array(conf.privateKey)) + .publicKey + ) + ); + privateKey = b64ToUint8Array(conf.privateKey); + } else if (conf.seed) { + const hex32 = crypto.sha256(conf.seed); + const uint8Array = new Uint8Array(Buffer.from(hex32, "hex")); + const naclKeyPair = nacl.sign.keyPair.fromSeed(uint8Array); + publicKey = uInt8ArrayToB64(Object.values(naclKeyPair.publicKey)); + privateKey = naclKeyPair.secretKey; + } + return { publicKey, privateKey }; +} + +function getEthKeyPair() { + let publicKey, privateKey; + if (conf.ethPrivateKey) { + privateKey = new Uint8Array(Buffer.from(conf.ethPrivateKey, "hex")); + publicKey = Buffer.from( + Object.values(secp256k1.publicKeyCreate(privateKey)) + ).toString("hex"); + } else if (conf.seed) { + const hex32 = crypto.sha256(conf.seed); + privateKey = new Uint8Array(Buffer.from(hex32, "hex")); + publicKey = Buffer.from( + Object.values(secp256k1.publicKeyCreate(privateKey)) + ).toString("hex"); + } + return { publicKey, privateKey }; +} + +function getConsensusSenderAddress() { + let address = null; + if (conf.consensusSenderPrivateKey) { + const uint8ArrayPrivateKey = new Uint8Array( + Buffer.from(conf.consensusSenderPrivateKey, "hex") + ); + address = priv2addr(uint8ArrayPrivateKey); + } else if (conf.seed) { + const hex32 = crypto.sha256(conf.seed); + const uint8ArrayPrivateKey = new Uint8Array(Buffer.from(hex32, "hex")); + address = priv2addr(uint8ArrayPrivateKey); + } + return address; +} + +function recoverEthSigner(signature, message) { + message = `\x19Ethereum Signed Message:\n${message.length}${message}`; + const hexMessage = Buffer.from(message, "binary").toString("hex"); + const hashMessage = new Uint8Array( + createKeccakHash("keccak256").update(hexMessage, "hex").digest() + ); + const recid = parseInt(signature.slice(-2), 16) - 27; + signature = new Uint8Array(Buffer.from(signature.slice(0, -2), "hex")); + let publicKey = secp256k1.ecdsaRecover(signature, recid, hashMessage); + publicKey = Buffer.from( + secp256k1.publicKeyConvert(publicKey, false).buffer + ).slice(1); + publicKey = publicKey.toString("hex"); + publicKey = createKeccakHash("keccak256").update(publicKey, "hex").digest(); + return `0x${publicKey.slice(-20).toString("hex")}`; +} + +module.exports = { + uInt8ArrayToB64, + b64ToUint8Array, + strToUint8Array, + b64ToUrlSafeB64, + urlSafeB64ToB64, + hash, + pad32, + addressToBytes32, + priv2addr, + getNaclKeyPair, + getEthKeyPair, + getConsensusSenderAddress, + recoverEthSigner, +}; diff --git a/web_services/foxx/brightid5/errors.js b/web_services/foxx/brightid5/errors.js new file mode 100755 index 00000000..3343cc74 --- /dev/null +++ b/web_services/foxx/brightid5/errors.js @@ -0,0 +1,576 @@ +const CONTEXT_NOT_FOUND = 1; +const CONTEXTID_NOT_FOUND = 2; +const NOT_VERIFIED = 3; +const NOT_SPONSORED = 4; +const KEYPAIR_NOT_SET = 6; +const ETHPRIVATEKEY_NOT_SET = 7; +const OPERATION_NOT_FOUND = 9; +const USER_NOT_FOUND = 10; +const IP_NOT_SET = 11; +const APP_NOT_FOUND = 12; +const INVALID_EXPRESSION = 13; +const INVALID_TESTING_KEY = 14; +const INVALID_PASSCODE = 15; +const PASSCODE_NOT_SET = 16; +const GROUP_NOT_FOUND = 17; +const INVALID_OPERATION_NAME = 18; +const INVALID_SIGNATURE = 19; +const TOO_MANY_OPERATIONS = 20; +const INVALID_OPERATION_VERSION = 21; +const INVALID_TIMESTAMP = 22; +const NOT_RECOVERY_CONNECTIONS = 23; +const INVALID_HASH = 24; +const OPERATION_APPLIED_BEFORE = 25; +const TOO_BIG_OPERATION = 26; +const INELIGIBLE_NEW_USER = 27; +const ALREADY_HAS_PRIMARY_GROUP = 28; +const NEW_USER_BEFORE_FOUNDERS_JOIN = 29; +const INVALID_GROUP_TYPE = 30; +const DUPLICATE_GROUP = 31; +const INVALID_COFOUNDERS = 32; +const INELIGIBLE_NEW_ADMIN = 33; +const NOT_INVITED = 34; +const LEAVE_GROUP = 35; +const DUPLICATE_CONTEXTID = 36; +const TOO_MANY_LINK_REQUEST = 37; +const UNUSED_SPONSORSHIPS = 38; +const SPONSORED_BEFORE = 39; +const SPONSOR_NOT_SUPPORTED = 40; +const NOT_ADMIN = 41; +const ARANGO_ERROR = 42; +const INELIGIBLE_RECOVERY_CONNECTION = 43; +const INVALID_CONTEXTID = 44; +const APP_AUTHORIZED_BEFORE = 45; +const SPEND_REQUESTED_BEFORE = 46; +const SPONSOR_REQUESTED_RECENTLY = 47; + +class BrightIDError extends Error { + constructor() { + super(); + this.name = "BrightIDError"; + this.date = new Date(); + } +} + +class BadRequestError extends BrightIDError { + constructor() { + super(); + this.code = 400; + this.message = "Bad Request"; + } +} + +class UnauthorizedError extends BrightIDError { + constructor() { + super(); + this.code = 401; + this.message = "Unauthorized"; + } +} + +class ForbiddenError extends BrightIDError { + constructor() { + super(); + this.code = 403; + this.message = "Forbidden"; + } +} + +class NotFoundError extends BrightIDError { + constructor() { + super(); + this.code = 404; + this.message = "Not Found"; + } +} + +class TooManyRequestsError extends BrightIDError { + constructor() { + super(); + this.code = 429; + this.message = "Too Many Requests"; + } +} + +class InternalServerError extends BrightIDError { + constructor() { + super(); + this.code = 500; + this.message = "Internal Server Error"; + } +} + +class InvalidSignatureError extends UnauthorizedError { + constructor() { + super(); + this.errorNum = INVALID_SIGNATURE; + this.message = "Signature is not valid."; + } +} + +class AppNotFoundError extends NotFoundError { + constructor(app) { + super(); + this.errorNum = APP_NOT_FOUND; + this.message = `${app} app is not found.`; + this.app = app; + } +} + +class TooManyOperationsError extends TooManyRequestsError { + constructor(senders, waitingTime, timeWindow, limit) { + super(); + this.errorNum = TOO_MANY_OPERATIONS; + this.message = `More than ${limit} operations sent from ${senders.join( + ", " + )} in ${parseInt(timeWindow / 1000)} seconds. Try again after ${parseInt( + waitingTime / 1000 + )} seconds.`; + this.senders = senders; + this.waitingTime = waitingTime; + this.timeWindow = timeWindow; + this.limit = limit; + } +} + +class InvalidOperationNameError extends BadRequestError { + constructor(name) { + super(); + this.errorNum = INVALID_OPERATION_NAME; + this.message = `${name} is not a valid operation name.`; + this.name = name; + } +} + +class InvalidOperationVersionError extends BadRequestError { + constructor(v) { + super(); + this.errorNum = INVALID_OPERATION_VERSION; + this.message = `${v} is not a valid operation version.`; + this.v = v; + } +} + +class InvalidOperationTimestampError extends ForbiddenError { + constructor(timestamp) { + super(); + this.errorNum = INVALID_TIMESTAMP; + this.message = `The timestamp (${timestamp}) is in the future.`; + this.timestamp = timestamp; + } +} + +class InvalidOperationHashError extends BadRequestError { + constructor() { + super(); + this.errorNum = INVALID_HASH; + this.message = "Operation hash is not valid."; + } +} + +class NotRecoveryConnectionsError extends ForbiddenError { + constructor() { + super(); + this.errorNum = NOT_RECOVERY_CONNECTIONS; + this.message = "Signers of the request are not recovery connections."; + } +} + +class OperationNotFoundError extends NotFoundError { + constructor(hash) { + super(); + this.errorNum = OPERATION_NOT_FOUND; + this.message = `The operation ${hash} is not found.`; + this.hash = hash; + } +} + +class OperationAppliedBeforeError extends ForbiddenError { + constructor(hash) { + super(); + this.errorNum = OPERATION_APPLIED_BEFORE; + this.message = `The Operation ${hash} was applied before.`; + this.hash = hash; + } +} + +class TooBigOperationError extends ForbiddenError { + constructor(limit) { + super(); + this.errorNum = TOO_BIG_OPERATION; + this.message = `The Operation is bigger than ${limit} bytes limit.`; + this.limit = limit; + } +} + +class UserNotFoundError extends NotFoundError { + constructor(user) { + super(); + this.errorNum = USER_NOT_FOUND; + this.message = `The user ${user} is not found.`; + this.user = user; + } +} + +class ContextNotFoundError extends NotFoundError { + constructor(context) { + super(); + this.errorNum = CONTEXT_NOT_FOUND; + this.message = `The context ${context} is not found.`; + this.context = context; + } +} + +class ContextIdNotFoundError extends NotFoundError { + constructor(contextId) { + super(); + this.errorNum = CONTEXTID_NOT_FOUND; + this.message = `The contextId ${contextId} is not linked.`; + this.contextId = contextId; + } +} + +class GroupNotFoundError extends NotFoundError { + constructor(group) { + super(); + this.errorNum = GROUP_NOT_FOUND; + this.message = `The group ${group} is not found.`; + this.group = group; + } +} + +class NotSponsoredError extends ForbiddenError { + constructor(contextId) { + super(); + this.errorNum = NOT_SPONSORED; + this.message = `The user linked to the contextId ${contextId} is not sponsored.`; + this.contextId = contextId; + } +} + +class NotVerifiedError extends ForbiddenError { + constructor(contextId, app) { + super(); + this.errorNum = NOT_VERIFIED; + this.message = `The linked user is not verified for ${app} app.`; + this.contextId = contextId; + this.app = app; + } +} + +class InvalidExpressionError extends InternalServerError { + constructor(app, expression, err) { + super(); + this.errorNum = INVALID_EXPRESSION; + this.message = `Evaluating verification expression for ${app} app failed. Expression: "${expression}", Error: ${err}`; + } +} + +class KeypairNotSetError extends InternalServerError { + constructor() { + super(); + this.errorNum = KEYPAIR_NOT_SET; + this.message = + "BN_WS_PUBLIC_KEY or BN_WS_PRIVATE_KEY are not set in config.env."; + } +} + +class EthPrivatekeyNotSetError extends InternalServerError { + constructor() { + super(); + this.errorNum = ETHPRIVATEKEY_NOT_SET; + this.message = "BN_WS_ETH_PRIVATE_KEY is not set."; + } +} + +class IpNotSetError extends InternalServerError { + constructor() { + super(); + this.errorNum = IP_NOT_SET; + this.message = + "BN_WS_IP variable is not set in config.env and is not automatically loaded for an unknown reason."; + } +} + +class InvalidTestingKeyError extends ForbiddenError { + constructor() { + super(); + this.errorNum = INVALID_TESTING_KEY; + this.message = "Invalid testing key."; + } +} + +class PasscodeNotSetError extends ForbiddenError { + constructor(context) { + super(); + this.errorNum = PASSCODE_NOT_SET; + this.message = `Passcode is not set on the remote node for the ${context} context.`; + this.context = context; + } +} + +class InvalidPasscodeError extends ForbiddenError { + constructor() { + super(); + this.errorNum = INVALID_PASSCODE; + this.message = "Invalid passcode."; + } +} + +class NotAdminError extends ForbiddenError { + constructor() { + super(); + this.errorNum = NOT_ADMIN; + this.message = "Requstor is not admin of the group."; + } +} + +class AlreadyHasPrimaryGroupError extends ForbiddenError { + constructor() { + super(); + this.errorNum = ALREADY_HAS_PRIMARY_GROUP; + this.message = "The user already has a primary group."; + } +} + +class NewUserBeforeFoundersJoinError extends ForbiddenError { + constructor() { + super(); + this.errorNum = NEW_USER_BEFORE_FOUNDERS_JOIN; + this.message = "New members can not join before founders join the group."; + } +} + +class InvalidGroupTypeError extends ForbiddenError { + constructor(type) { + super(); + this.errorNum = INVALID_GROUP_TYPE; + this.message = `${type} is not a valid group type.`; + this.type = type; + } +} + +class DuplicateGroupError extends ForbiddenError { + constructor() { + super(); + this.errorNum = DUPLICATE_GROUP; + this.message = "Group with this id already exists."; + } +} + +class InvalidCoFoundersError extends ForbiddenError { + constructor() { + super(); + this.errorNum = INVALID_COFOUNDERS; + this.message = + "One or both of the co-founders are not connected to the founder."; + } +} + +class IneligibleNewAdminError extends ForbiddenError { + constructor() { + super(); + this.errorNum = INELIGIBLE_NEW_ADMIN; + this.message = "New admin is not member of the group."; + } +} + +class NotInvitedError extends ForbiddenError { + constructor() { + super(); + this.errorNum = NOT_INVITED; + this.message = "The user is not invited to join this group."; + } +} + +class LeaveGroupError extends ForbiddenError { + constructor() { + super(); + this.errorNum = LEAVE_GROUP; + this.message = + "Last admin can not leave the group when it still has other members."; + } +} + +class DuplicateContextIdError extends ForbiddenError { + constructor(contextId) { + super(); + this.errorNum = DUPLICATE_CONTEXTID; + this.message = `The contextId ${contextId} is used by another user before.`; + this.contextId = contextId; + } +} + +class TooManyLinkRequestError extends TooManyRequestsError { + constructor() { + super(); + this.errorNum = TOO_MANY_LINK_REQUEST; + this.message = "Only three contextIds can be linked every 24 hours."; + } +} + +class UnusedSponsorshipsError extends ForbiddenError { + constructor(app) { + super(); + this.errorNum = UNUSED_SPONSORSHIPS; + this.message = `${app} app does not have unused sponsorships.`; + this.app = app; + } +} + +class SponsoredBeforeError extends ForbiddenError { + constructor() { + super(); + this.errorNum = SPONSORED_BEFORE; + this.message = "The user has been sponsored before."; + } +} + +class SponsorNotSupportedError extends ForbiddenError { + constructor(app) { + super(); + this.errorNum = SPONSOR_NOT_SUPPORTED; + this.message = `This node can not relay sponsor requests for ${app} app.`; + this.app = app; + } +} + +class IneligibleRecoveryConnection extends ForbiddenError { + constructor() { + super(); + this.errorNum = INELIGIBLE_RECOVERY_CONNECTION; + this.message = + "Recovery level can only be selected for connections that already know you or trust you as their recovery connection."; + } +} + +class InvalidContextIdError extends NotFoundError { + constructor(contextId) { + super(); + this.errorNum = INVALID_CONTEXTID; + this.message = `The contextId ${contextId} is not valid.`; + this.contextId = contextId; + } +} + +class AppAuthorizedBeforeError extends ForbiddenError { + constructor() { + super(); + this.errorNum = APP_AUTHORIZED_BEFORE; + this.message = + "The app authorized a sponsorship for this contextId before."; + } +} + +class SpendRequestedBeforeError extends ForbiddenError { + constructor() { + super(); + this.errorNum = SPEND_REQUESTED_BEFORE; + this.message = "Spend request for this contextId submitted before."; + } +} + +class SponsorRequestedRecently extends ForbiddenError { + constructor() { + super(); + this.errorNum = SPONSOR_REQUESTED_RECENTLY; + this.message = `The app has sent this sponsor request recently.`; + } +} + +module.exports = { + CONTEXT_NOT_FOUND, + CONTEXTID_NOT_FOUND, + NOT_VERIFIED, + NOT_SPONSORED, + KEYPAIR_NOT_SET, + ETHPRIVATEKEY_NOT_SET, + OPERATION_NOT_FOUND, + USER_NOT_FOUND, + IP_NOT_SET, + APP_NOT_FOUND, + INVALID_EXPRESSION, + INVALID_TESTING_KEY, + INVALID_PASSCODE, + PASSCODE_NOT_SET, + GROUP_NOT_FOUND, + INVALID_OPERATION_NAME, + INVALID_SIGNATURE, + TOO_MANY_OPERATIONS, + INVALID_OPERATION_VERSION, + INVALID_TIMESTAMP, + NOT_RECOVERY_CONNECTIONS, + INVALID_HASH, + OPERATION_APPLIED_BEFORE, + TOO_BIG_OPERATION, + ALREADY_HAS_PRIMARY_GROUP, + NEW_USER_BEFORE_FOUNDERS_JOIN, + INVALID_GROUP_TYPE, + DUPLICATE_GROUP, + INVALID_COFOUNDERS, + INELIGIBLE_NEW_ADMIN, + NOT_INVITED, + LEAVE_GROUP, + DUPLICATE_CONTEXTID, + TOO_MANY_LINK_REQUEST, + UNUSED_SPONSORSHIPS, + SPONSORED_BEFORE, + SPONSOR_NOT_SUPPORTED, + NOT_ADMIN, + ARANGO_ERROR, + INELIGIBLE_RECOVERY_CONNECTION, + INVALID_CONTEXTID, + APP_AUTHORIZED_BEFORE, + SPEND_REQUESTED_BEFORE, + SPONSOR_REQUESTED_RECENTLY, + BrightIDError, + BadRequestError, + InternalServerError, + TooManyRequestsError, + UnauthorizedError, + NotFoundError, + ForbiddenError, + InvalidSignatureError, + AppNotFoundError, + TooManyOperationsError, + InvalidOperationNameError, + InvalidOperationVersionError, + InvalidOperationTimestampError, + InvalidOperationHashError, + NotRecoveryConnectionsError, + OperationNotFoundError, + OperationAppliedBeforeError, + TooBigOperationError, + UserNotFoundError, + ContextNotFoundError, + ContextIdNotFoundError, + GroupNotFoundError, + NotSponsoredError, + NotVerifiedError, + InvalidExpressionError, + KeypairNotSetError, + EthPrivatekeyNotSetError, + IpNotSetError, + InvalidTestingKeyError, + PasscodeNotSetError, + InvalidPasscodeError, + NotAdminError, + AlreadyHasPrimaryGroupError, + NewUserBeforeFoundersJoinError, + InvalidGroupTypeError, + DuplicateGroupError, + InvalidCoFoundersError, + IneligibleNewAdminError, + NotInvitedError, + LeaveGroupError, + DuplicateContextIdError, + TooManyLinkRequestError, + UnusedSponsorshipsError, + SponsoredBeforeError, + SponsorNotSupportedError, + IneligibleRecoveryConnection, + InvalidContextIdError, + AppAuthorizedBeforeError, + SpendRequestedBeforeError, + SponsorRequestedRecently, +}; diff --git a/web_services/foxx/brightid5/index.js b/web_services/foxx/brightid5/index.js new file mode 100755 index 00000000..b2abcbb7 --- /dev/null +++ b/web_services/foxx/brightid5/index.js @@ -0,0 +1,778 @@ +"use strict"; +const secp256k1 = require("secp256k1"); +const createKeccakHash = require("keccak"); +const createRouter = require("@arangodb/foxx/router"); +const _ = require("lodash"); +const joi = require("joi"); +const { db: arango, ArangoError } = require("@arangodb"); +const nacl = require("tweetnacl"); +const db = require("./db"); +const schemas = require("./schemas").schemas; +const operations = require("./operations"); +const { + strToUint8Array, + uInt8ArrayToB64, + hash, + pad32, + addressToBytes32, + getNaclKeyPair, + getEthKeyPair, +} = require("./encoding"); +const errors = require("./errors"); + +const router = createRouter(); +module.context.use(router); +const operationsHashesColl = arango._collection("operationsHashes"); + +const MAX_OP_SIZE = 2000; + +const handlers = { + operationsPost: function (req, res) { + const op = req.body; + const message = operations.getMessage(op); + op.hash = hash(message); + + if (operationsHashesColl.exists(op.hash)) { + throw new errors.OperationAppliedBeforeError(op.hash); + } else if (JSON.stringify(op).length > MAX_OP_SIZE) { + throw new errors.TooBigOperationError(MAX_OP_SIZE); + } + + // verify signature + operations.verify(op); + + // allow limited number of operations to be posted in defined time window + const timeWindow = module.context.configuration.operationsTimeWindow * 1000; + const limit = ["Sponsor", "Spend Sponsorship"].includes(op.name) + ? module.context.configuration.appsOperationsLimit + : module.context.configuration.operationsLimit; + operations.checkLimits(op, timeWindow, limit); + + if (op.name == "Link ContextId") { + operations.encrypt(op); + } + + op.state = "init"; + db.upsertOperation(op); + res.send({ + data: { + hash: op.hash, + }, + }); + }, + + operationGet: function (req, res) { + const hash = req.param("hash"); + const op = db.loadOperation(hash); + if (op) { + res.send({ + data: { + state: op.state, + result: op.result, + }, + }); + } else { + throw new errors.OperationNotFoundError(hash); + } + }, + + userGet: function (req, res) { + const id = req.param("id"); + const user = db.loadUser(id); + if (!user) { + throw new errors.UserNotFoundError(id); + } + + const verifications = db.userVerifications(id).map((v) => v.name); + + let connections = db.userConnections(id); + const connectionsMap = _.keyBy(connections, (conn) => conn.id); + connections = connections.map((conn) => { + const u = db.userToDic(conn.id); + u.level = connectionsMap[conn.id].level; + u.reportReason = connectionsMap[conn.id].reportReason; + return u; + }); + + let groups = db.userGroups(id); + groups = groups.map((group) => { + const g = db.groupToDic(group.id); + g.joined = group.timestamp; + return g; + }); + + const invites = db.userInvitedGroups(id); + // this is deprecated and will be removed on v6 + db.updateEligibleGroups(id, connections, groups); + + res.send({ + data: { + score: user.score, + createdAt: user.createdAt, + flaggers: db.getReporters(id), + trusted: db.getRecoveryConnections(id), + invites, + groups, + connections, + verifications, + isSponsored: db.isSponsored(id), + signingKeys: user.signingKeys, + }, + }); + }, + + userConnectionsGet: function (req, res) { + const id = req.param("id"); + const direction = req.param("direction"); + res.send({ + data: { + connections: db.userConnections(id, direction), + }, + }); + }, + + userVerificationsGet: function (req, res) { + const id = req.param("id"); + res.send({ + data: { + verifications: db.userVerifications(id), + }, + }); + }, + + userProfileGet: function (req, res) { + const id = req.param("id"); + const requestor = req.param("requestor"); + const user = db.loadUser(id); + if (!user) { + throw new errors.UserNotFoundError(id); + } + + const verifications = db.userVerifications(id); + const connections = db.userConnections(id, "inbound"); + const groups = db.userGroups(id); + const requestorConnections = db.userConnections(requestor, "outbound"); + const requestorGroups = db.userGroups(requestor); + + const isKnown = (c) => + ["just met", "already known", "recovery"].includes(c.level); + const connectionsNum = connections.filter(isKnown).length; + const groupsNum = groups.length; + const mutualConnections = _.intersection( + connections.filter(isKnown).map((c) => c.id), + requestorConnections.filter(isKnown).map((c) => c.id) + ); + const mutualGroups = _.intersection( + groups.map((g) => g.id), + requestorGroups.map((g) => g.id) + ); + + const conn = connections.find((c) => c.id === requestor); + const connectedAt = conn ? conn.timestamp : 0; + const reports = connections + .filter((c) => c.level === "reported") + .map((c) => { + return { + id: c.id, + reportReason: c.reportReason, + }; + }); + + res.send({ + data: { + connectionsNum, + groupsNum, + mutualConnections, + mutualGroups, + connectedAt, + createdAt: user.createdAt, + reports, + verifications, + signingKeys: user.signingKeys, + }, + }); + }, + + allVerificationsGet: function (req, res) { + const appKey = req.param("app"); + const countOnly = "count_only" in req.queryParams; + const data = db.getLastContextIds(appKey, countOnly); + res.send({ + data, + }); + }, + + verificationGet: function (req, res) { + let unique = true; + let contextId = req.param("contextId"); + let appKey = req.param("app"); + const signed = req.param("signed"); + let timestamp = req.param("timestamp"); + const verification = req.param("verification"); + const app = db.getApp(appKey); + const context = db.getContext(app.context); + if (context.idsAsHex) { + contextId = contextId.toLowerCase(); + } + const testblocks = db.getTestblocks(appKey, contextId); + + if (testblocks.includes("link")) { + throw new errors.ContextIdNotFoundError(contextId); + } else if (testblocks.includes("sponsorship")) { + throw new errors.NotSponsoredError(contextId); + } else if (testblocks.includes("verification")) { + throw new errors.NotVerifiedError(appKey); + } + + const coll = arango._collection(context.collection); + const user = db.getUserByContextId(coll, contextId); + if (!user) { + const sponsorship = db.getSponsorship(contextId); + if (sponsorship.appHasAuthorized) { + throw new errors.ContextIdNotFoundError(contextId); + } else { + throw new errors.NotSponsoredError(contextId); + } + } + + if (!db.isVerifiedFor(user, verification || app.verification)) { + throw new errors.NotVerifiedError(appKey); + } + + let contextIds = db.getContextIdsByUser(coll, user); + if (contextId != contextIds[0]) { + unique = false; + } + + if (timestamp == "seconds") { + timestamp = parseInt(Date.now() / 1000); + } else if (timestamp == "milliseconds") { + timestamp = Date.now(); + } else { + timestamp = undefined; + } + + // sign and return the verification + let sig, publicKey; + if (signed == "nacl") { + const naclKeyPair = getNaclKeyPair(); + if (!naclKeyPair.privateKey) { + throw new errors.KeypairNotSetError(); + } + + let message = appKey + "," + contextIds.join(","); + if (timestamp) { + message = message + "," + timestamp; + } + publicKey = naclKeyPair.publicKey; + sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), naclKeyPair.privateKey) + ) + ); + } else if (signed == "eth") { + const ethKeyPair = getEthKeyPair(); + if (!ethKeyPair.privateKey) { + throw new errors.EthPrivatekeyNotSetError(); + } + + let message, h; + if (context.idsAsHex) { + message = pad32(appKey) + contextIds.map(addressToBytes32).join(""); + } else { + message = pad32(appKey) + contextIds.map(pad32).join(""); + } + message = Buffer.from(message, "binary").toString("hex"); + if (timestamp) { + const t = timestamp.toString(16); + message += "0".repeat(64 - t.length) + t; + } + h = new Uint8Array( + createKeccakHash("keccak256").update(message, "hex").digest() + ); + publicKey = ethKeyPair.publicKey; + const _sig = secp256k1.ecdsaSign(h, ethKeyPair.privateKey); + sig = { + r: Buffer.from(Object.values(_sig.signature.slice(0, 32))).toString( + "hex" + ), + s: Buffer.from(Object.values(_sig.signature.slice(32, 64))).toString( + "hex" + ), + v: _sig.recid + 27, + }; + } + res.send({ + data: { + unique, + app: appKey, + context: app.context, + contextIds: contextIds, + sig, + timestamp, + publicKey, + }, + }); + }, + + ipGet: function (req, res) { + let ip = + module.context && + module.context.configuration && + module.context.configuration.ip; + if (ip) { + res.send({ + data: { + ip, + }, + }); + } else { + throw new errors.IpNotSetError(); + } + }, + + appGet: function (req, res) { + const appKey = req.param("app"); + let app = db.getApp(appKey); + res.send({ + data: db.appToDic(app), + }); + }, + + allAppsGet: function (req, res) { + const apps = db.getApps().map((app) => db.appToDic(app)); + apps.sort((app1, app2) => { + const used1 = app1.assignedSponsorships - app1.unusedSponsorships; + const unused1 = app1.unusedSponsorships; + const used2 = app2.assignedSponsorships - app2.unusedSponsorships; + const unused2 = app2.unusedSponsorships; + return unused2 * used2 - unused1 * used1; + }); + res.send({ + data: { + apps, + }, + }); + }, + + stateGet: function (req, res) { + res.send({ + data: db.getState(), + }); + }, + + testblocksPut: function (req, res) { + const appKey = req.param("app"); + const action = req.param("action"); + let contextId = req.param("contextId"); + const testingKey = req.param("testingKey"); + + const app = db.getApp(appKey); + if (app.testingKey != testingKey) { + throw new errors.InvalidTestingKeyError(); + } + const context = db.getContext(app.context); + if (context.idsAsHex) { + if (!db.isEthereumAddress(contextId)) { + throw new errors.InvalidContextIdError(contextId); + } + contextId = contextId.toLowerCase(); + } + + return db.addTestblock(contextId, action, appKey); + }, + + testblocksDelete: function (req, res) { + const appKey = req.param("app"); + const action = req.param("action"); + let contextId = req.param("contextId"); + const testingKey = req.param("testingKey"); + + const app = db.getApp(appKey); + if (app.testingKey != testingKey) { + throw new errors.InvalidTestingKeyError(); + } + const context = db.getContext(app.context); + if (context.idsAsHex) { + if (!db.isEthereumAddress(contextId)) { + throw new errors.InvalidContextIdError(contextId); + } + contextId = contextId.toLowerCase(); + } + return db.removeTestblock(contextId, action, appKey); + }, + + contextDumpGet: function (req, res) { + const contextKey = req.param("context"); + const passcode = req.queryParams["passcode"]; + const context = db.getContext(contextKey); + + if (!context.passcode) { + throw new errors.PasscodeNotSetError(contextKey); + } + if (context.passcode != passcode) { + throw new errors.InvalidPasscodeError(); + } + + const coll = arango._collection(context.collection); + const contextIds = db.getContextIds(coll); + db.removePasscode(contextKey); + res.send({ + data: { + collection: context.collection, + idsAsHex: context.idsAsHex, + linkAESKey: context.linkAESKey, + contextIds, + }, + }); + }, + + groupGet: function (req, res) { + const id = req.param("id"); + const group = db.loadGroup(id); + if (!group) { + throw new errors.GroupNotFoundError(id); + } + + res.send({ + data: { + members: db.groupMembers(id), + invites: db.groupInvites(id), + // the eligibles is deprecated and will be removed on v6 + eligibles: db.updateEligibles(id), + admins: group.admins, + founders: group.founders, + isNew: group.isNew, + seed: group.seed || false, + region: group.region, + type: group.type || "general", + url: group.url, + info: group.info, + timestamp: group.timestamp, + }, + }); + }, + + sponsorshipGet: function (req, res) { + let appKey = req.param("app"); + let contextId = req.param("contextId"); + if (db.isEthereumAddress(contextId)) { + contextId = contextId.toLowerCase(); + } + const sponsorship = db.getSponsorship(contextId); + res.send({ + data: { + app: sponsorship._to.replace("apps/", ""), + appHasAuthorized: sponsorship.appHasAuthorized, + spendRequested: sponsorship.spendRequested, + timestamp: sponsorship.timestamp, + }, + }); + }, +}; + +router + .post("/operations", handlers.operationsPost) + .body(schemas.operation) + .summary("Add an operation to be applied after consensus") + .description("Add an operation be applied after consensus.") + .response(schemas.operationPostResponse) + .error(400, "Failed to add the operation") + .error(403, "Bad signature") + .error(429, "Too Many Requests"); + +router + .get("/users/:id", handlers.userGet) + .pathParam( + "id", + joi.string().required().description("the brightid of the user") + ) + .summary("Get information about a user") + .description( + "Gets a user's score, verifications, joining date, lists of connections, groups and eligible groups." + ) + .response(schemas.userGetResponse) + .error(404, "User not found"); + +router + .get("/users/:id/verifications", handlers.userVerificationsGet) + .pathParam( + "id", + joi.string().required().description("the brightid of the user") + ) + .summary("Get verifications of a user") + .description("Gets list of user's verification objects with their properties") + .response(schemas.userVerificationsGetResponse); + +router + .get("/users/:id/profile/:requestor", handlers.userProfileGet) + .pathParam( + "id", + joi + .string() + .required() + .description("the brightid of the user that info requested about") + ) + .pathParam( + "requestor", + joi + .string() + .required() + .description("the brightid of the user that requested info") + ) + .summary("Get profile information of a user") + .response(schemas.userProfileGetResponse) + .error(404, "User not found"); + +router + .get("/users/:id/connections/:direction", handlers.userConnectionsGet) + .pathParam( + "id", + joi.string().required().description("the brightid of the user") + ) + .pathParam( + "direction", + joi + .string() + .required() + .valid("inbound", "outbound") + .description("the direction of the connection") + ) + .summary("Get inbound or outbound connections of a user") + .description("Gets list of user's connections with levels and timestamps") + .response(schemas.userConnectionsGetResponse); + +router + .get("/operations/:hash", handlers.operationGet) + .pathParam( + "hash", + joi.string().required().description("sha256 hash of the operation message") + ) + .summary("Get state and result of an operation") + .response(schemas.operationGetResponse) + .error(404, "Operation not found"); + +router + .get("/verifications/:app/:contextId", handlers.verificationGet) + .pathParam( + "app", + joi.string().required().description("the app that user is verified for") + ) + .pathParam( + "contextId", + joi + .string() + .required() + .description("the contextId of user within the context") + ) + .queryParam( + "verification", + joi.string().description("the verification expression") + ) + .queryParam( + "signed", + joi + .string() + .description( + "the value will be eth or nacl to indicate the type of signature returned" + ) + ) + .queryParam( + "timestamp", + joi + .string() + .description( + 'request a timestamp of the specified format to be added to the response. Accepted values: "seconds", "milliseconds"' + ) + ) + .summary("Gets a signed verification") + .description( + "Gets a signed verification for the user that is signed by the node" + ) + .response(schemas.verificationGetResponse) + .error(403, "user is not sponsored") + .error(404, "context, contextId or verification not found"); + +router + .get("/verifications/:app", handlers.allVerificationsGet) + .pathParam( + "app", + joi + .string() + .required() + .description("the app for which the user is verified") + ) + .summary("Gets list of all of contextIds verifed for an app") + .description( + "Gets list of all of contextIds in the context that are sponsored and verified for using an app" + ) + .response(schemas.allVerificationsGetResponse) + .error(404, "context not found"); + +// this route is deprecated and will be removed on v6 +router + .get("/ip", handlers.ipGet) + .summary("Get this server's IPv4 address") + .response(schemas.ipGetResponse); + +router + .get("/apps/:app", handlers.appGet) + .pathParam( + "app", + joi.string().required().description("Unique name of the app") + ) + .summary("Get information about an app") + .response(schemas.appGetResponse) + .error(404, "app not found"); + +router + .get("/apps", handlers.allAppsGet) + .summary("Get all apps") + .response(schemas.allAppsGetResponse); + +router + .get("/state", handlers.stateGet) + .summary("Get state of this node") + .response(schemas.stateGetResponse); + +router + .put("/testblocks/:app/:action/:contextId", handlers.testblocksPut) + .pathParam("app", joi.string().required().description("The key of app")) + .pathParam( + "action", + joi + .string() + .valid("sponsorship", "link", "verification") + .required() + .description("The action name") + ) + .pathParam( + "contextId", + joi + .string() + .required() + .description("the contextId of user within the context") + ) + .queryParam( + "testingKey", + joi.string().required().description("the secret key for testing the app") + ) + .summary("Block user's verification for testing") + .description( + "Updating state of contextId to be considered as unsponsored, unlinked or unverified temporarily for testing" + ) + .response(null); + +router + .delete("/testblocks/:app/:action/:contextId", handlers.testblocksDelete) + .pathParam( + "app", + joi.string().required().description("Unique name of the app") + ) + .pathParam( + "action", + joi + .string() + .required() + .valid("sponsorship", "link", "verification") + .description("The action name") + ) + .pathParam( + "contextId", + joi + .string() + .required() + .description("the contextId of user within the context") + ) + .queryParam( + "testingKey", + joi.string().description("the testing private key of the app") + ) + .summary("Remove blocking state applied on user's verification for testing") + .description( + "Remove limitations applied to a contextId to be considered as unsponsored, unlinked or unverified temporarily for testing" + ) + .response(null); + +router + .get("/contexts/:context/dump", handlers.contextDumpGet) + .pathParam("context", joi.string().required().description("the context key")) + .queryParam( + "passcode", + joi + .string() + .required() + .description( + "the one time passcode that authorize access to this endpoint once" + ) + ) + .summary("Get dump of a context") + .description("Get all required info to transfer a context to a new node") + .response(schemas.contextDumpGetResponse) + .error(404, "context not found") + .error(403, "passcode not set") + .error(403, "incorrect passcode"); + +router + .get("/groups/:id", handlers.groupGet) + .pathParam("id", joi.string().required().description("the id of the group")) + .summary("Get information about a group") + .description( + "Gets a group's admins, founders, info, isNew, region, seed, type, url, timestamp, members, invited and eligible members." + ) + .response(schemas.groupGetResponse) + .error(404, "Group not found"); + +router + .get("/sponsorships/:contextId", handlers.sponsorshipGet) + .pathParam( + "contextId", + joi + .string() + .required() + .description("the contextId of user within the context") + ) + .summary("Gets sponsorship information of a contextId") + .response(schemas.sponsorshipGetResponse) + .error(403, "user is not sponsored"); + +module.context.use(function (req, res, next) { + try { + next(); + } catch (e) { + if (e.cause && e.cause.isJoi) { + e.code = 400; + if ( + req._raw.url.includes("operations") && + e.cause.details && + e.cause.details.length > 0 + ) { + let msg1 = ""; + const msg2 = "invalid operation name"; + e.cause.details.forEach((d) => { + if (!d.message.includes('"name" must be one of')) { + msg1 += `${d.message}, `; + } + }); + e.message = msg1 || msg2; + } + } + if (!(e instanceof errors.NotFoundError)) { + console.group("Error returned"); + console.log("url:", req._raw.requestType, req._raw.url); + console.log("error:", e); + console.log("body:", req.body); + console.groupEnd(); + } + let options = undefined; + if (e instanceof ArangoError) { + options = { extra: { arangoErrorNum: e.errorNum } }; + e.errorNum = errors.ARANGO_ERROR; + } + res.throw(e.code || 500, e, options); + } +}); + +module.exports = { + handlers, +}; diff --git a/web_services/foxx/brightid5/initdb.js b/web_services/foxx/brightid5/initdb.js new file mode 100755 index 00000000..373b7ab6 --- /dev/null +++ b/web_services/foxx/brightid5/initdb.js @@ -0,0 +1,472 @@ +const arango = require("@arangodb").db; +const db = require("./db"); +const { query } = require("@arangodb"); + +const collections = { + connections: "edge", + connectionsHistory: "edge", + groups: "document", + usersInGroups: "edge", + users: "document", + contexts: "document", + apps: "document", + sponsorships: "edge", + operations: "document", + operationsHashes: "document", + invitations: "edge", + variables: "document", + verifications: "document", + testblocks: "document", + operationCounters: "document", +}; + +// deprecated collections should be added to this array after releasing +// second update to allow 2 last released versions work together +const deprecated = ["removed", "newGroups", "usersInNewGroups"]; + +const indexes = [ + { collection: "verifications", fields: ["user"], type: "persistent" }, + { collection: "verifications", fields: ["name"], type: "persistent" }, + { collection: "verifications", fields: ["block"], type: "persistent" }, + { + collection: "sponsorships", + fields: ["expireDate"], + type: "ttl", + expireAfter: 0, + }, + { collection: "sponsorships", fields: ["appId"], type: "persistent" }, + { collection: "connections", fields: ["level"], type: "persistent" }, + { + collection: "connectionsHistory", + fields: ["timestamp"], + type: "persistent", + }, + { collection: "groups", fields: ["seed"], type: "persistent" }, + { collection: "operations", fields: ["state"], type: "persistent" }, + { + collection: "operationCounters", + fields: ["expireDate"], + type: "ttl", + expireAfter: 0, + }, +]; + +const variables = [ + { _key: "LAST_DB_UPGRADE", value: -1 }, + { _key: "VERIFICATIONS_HASHES", hashes: "{}" }, + { _key: "VERIFICATION_BLOCK", value: 0 }, + // 2021/02/09 as starting point for applying new seed connected + { _key: "PREV_SNAPSHOT_TIME", value: 1612900000 }, +]; + +const variablesColl = arango._collection("variables"); + +function createCollections() { + console.log("creating collections if they do not exist ..."); + for (let collection in collections) { + const coll = arango._collection(collection); + if (coll) { + console.log(`${collection} exists`); + } else { + const type = collections[collection]; + arango._create(collection, {}, type); + console.log(`${collection} created with type ${type}`); + } + } +} + +function createIndexes() { + console.log("creating indexes ..."); + for (let index of indexes) { + const coll = arango._collection(index.collection); + console.log(`${index.fields} indexed in ${index.collection} collection`); + delete index.collection; + coll.ensureIndex(index); + } +} + +function removeDeprecatedCollections() { + console.log("removing deprecated collections"); + for (let collection of deprecated) { + const coll = arango._collection(collection); + if (coll) { + arango._drop(collection); + console.log(`${collection} dropped`); + } else { + console.log(`${collection} dropped before`); + } + } +} + +function initializeVariables() { + console.log("initialize variables ..."); + for (let variable of variables) { + if (!variablesColl.exists(variable._key)) { + variablesColl.insert(variable); + } + } +} + +function v5() { + const contextsColl = arango._collection("contexts"); + const appsColl = arango._collection("apps"); + const contexts = contextsColl.all().toArray(); + for (let context of contexts) { + appsColl.insert({ + _key: context["_key"], + name: context["_key"], + context: context["_key"], + url: context["appUrl"], + logo: context["appLogo"], + totalSponsorships: context["totalSponsorships"], + sponsorPublicKey: context["sponsorPublicKey"], + sponsorPrivateKey: context["sponsorPrivateKey"], + sponsorEventContract: context["contractAddress"], + wsProvider: + context["wsProvider"] || + "wss://mainnet.infura.io/ws/v3/36e48f8228ad42a297049cabc1101324", + }); + contextsColl.replace(context, { + collection: context["collection"], + verification: context["verification"], + linkAESKey: context["linkAESKey"], + idsAsHex: context["idsAsHex"], + ethName: context["ethName"], + }); + } + const sponsorshipsColl = arango._collection("sponsorships"); + const sponsorships = sponsorshipsColl.all().toArray(); + for (let sponsorship of sponsorships) { + sponsorshipsColl.update(sponsorship, { + _to: sponsorship["_to"].replace("contexts/", "apps/"), + }); + } +} + +function v5_3() { + const usersColl = arango._collection("users"); + const connectionsColl = arango._collection("connections"); + const timestamp = Date.now(); + + connectionsColl + .all() + .toArray() + .forEach((conn) => { + const key1 = conn._from.replace("users/", ""); + const key2 = conn._to.replace("users/", ""); + if (conn.timestamp < 1597276800000) { + // 08/13/2020 12:00am (UTC) + db.connect({ + id1: key1, + id2: key2, + level: "already known", + timestamp: conn.timestamp, + }); + db.connect({ + id1: key2, + id2: key1, + level: "already known", + timestamp: conn.timestamp, + }); + } else { + db.connect({ + id1: key1, + id2: key2, + level: "just met", + timestamp: conn.timestamp, + }); + db.connect({ + id1: key2, + id2: key1, + level: "just met", + timestamp: conn.timestamp, + }); + } + }); + usersColl + .all() + .toArray() + .forEach((user) => { + if (user.trusted) { + for (let conn of user.trusted) { + db.connect({ + id1: user._key, + id2: conn, + level: "recovery", + timestamp: user.updateTime, + }); + } + } + if (user.flaggers) { + for (let flagger in user.flaggers) { + db.connect({ + id1: flagger, + id2: user._key, + level: "reported", + reportReason: user.flaggers[flagger], + timestamp, + }); + } + } + }); +} + +function v5_5() { + console.log("add current connections to the connectionsHistory"); + const connectionsColl = arango._collection("connections"); + const connectionsHistoryColl = arango._collection("connectionsHistory"); + connectionsColl + .all() + .toArray() + .forEach((conn) => { + connectionsHistoryColl.insert({ + _from: conn["_from"], + _to: conn["_to"], + level: conn["level"], + reportReason: conn["reportReason"], + replacedWith: conn["replacedWith"], + requestProof: conn["requestProof"], + timestamp: conn["timestamp"], + }); + }); + + console.log("removing 'score' attribute form groups collection"); + const groupsColl = arango._collection("groups"); + query` + FOR doc IN ${groupsColl} + REPLACE UNSET(doc, 'score') IN ${groupsColl}`; + + console.log( + "removing 'score', 'verifications', 'flaggers', 'trusted' attributes form users collection" + ); + const usersColl = arango._collection("users"); + query` + FOR doc IN ${usersColl} + REPLACE doc WITH UNSET(doc, 'score', 'verifications', 'flaggers', 'trusted') IN ${usersColl}`; + + console.log("removing 'verification' attribute form contexts collection"); + const contextsColl = arango._collection("contexts"); + query` + FOR doc IN ${contextsColl} + REPLACE doc WITH UNSET(doc, 'verification') IN ${contextsColl}`; + + console.log( + "removing 'Yekta_0', 'Yekta_1', 'Yekta_2', 'Yekta_3', 'Yekta_4', 'Yekta_5' documents form verifications collection" + ); + const verificationsColl = arango._collection("verifications"); + for (let verificationName of [ + "Yekta_0", + "Yekta_1", + "Yekta_2", + "Yekta_3", + "Yekta_4", + "Yekta_5", + ]) { + verificationsColl.removeByExample({ name: verificationName }); + } +} + +function v5_6() { + console.log("removing 'verification' attribute form contexts collection"); + const contextsColl = arango._collection("contexts"); + query` + FOR doc IN ${contextsColl} + REPLACE doc WITH UNSET(doc, 'verification') IN ${contextsColl}`; + + console.log("removing 'ethName' attribute form context collection"); + query` + FOR doc IN ${contextsColl} + REPLACE UNSET(doc, 'ethName') IN ${contextsColl}`; +} + +function v5_6_1() { + console.log("use _key instead of _id in admins and founders of groups"); + const groupsColl = arango._collection("groups"); + const groups = groupsColl.all().toArray(); + for (let group of groups) { + groupsColl.update(group, { + founders: group.founders.map((f) => f.replace("users/", "")), + admins: (group.admins || group.founders).map((a) => + a.replace("users/", "") + ), + }); + } +} + +function v5_7() { + console.log("change 'signingKey' to 'signingKeys' attribute in the users"); + query` + FOR u IN users + UPDATE { _key: u._key, signingKeys: [u.signingKey] } IN users`; + + query` + FOR u IN users + REPLACE UNSET(u, 'signingKey') IN users`; +} + +function v5_8() { + console.log( + "removing 'Yekta_0', 'Yekta_1', 'Yekta_2', 'Yekta_3', 'Yekta_4', 'Yekta_5' documents form verifications collection" + ); + const verificationsColl = arango._collection("verifications"); + for (let verificationName of [ + "Yekta_0", + "Yekta_1", + "Yekta_2", + "Yekta_3", + "Yekta_4", + "Yekta_5", + ]) { + verificationsColl.removeByExample({ name: verificationName }); + } + + console.log("adding block to verifications"); + block = variablesColl.document("VERIFICATION_BLOCK").value; + query` + FOR v in verifications + UPDATE { _key: v._key, block: ${block} } IN verifications`; + + console.log("adding initTimestamp to connections"); + query` + FOR c in connections + UPDATE { _key: c._key, initTimestamp: ( + FOR ch in connectionsHistory + FILTER ch._from == c._from AND ch._to == c._to + SORT ch.timestamp + LIMIT 1 + RETURN ch.timestamp + )[0] } IN connections`; +} + +function v5_9() { + console.log( + "reducing 'recovery' level to 'just met' for connections that another side is not 'already known' or 'recovery'" + ); + const connectionsColl = arango._collection("connections"); + const connectionsHistoryColl = arango._collection("connectionsHistory"); + const now = Date.now(); + connectionsColl + .byExample({ + level: "recovery", + }) + .toArray() + .forEach((ft) => { + const tf = connectionsColl.firstExample({ + _from: ft._to, + _to: ft._from, + }); + if (!tf || !["already known", "recovery"].includes(tf.level)) { + db.connect({ + id1: ft._from.replace("users/", ""), + id2: ft._to.replace("users/", ""), + level: "just met", + timestamp: now, + }); + } + }); + + console.log("removing invalid contextIds form contexts' collection"); + const re = new RegExp(/^0[xX][A-Fa-f0-9]+$/); + const contextsColl = arango._collection("contexts"); + contextsColl + .all() + .toArray() + .map((context) => { + const contextColl = arango._collection(context.collection); + if (!contextColl) { + return; + } + const docs = contextColl.all().toArray(); + for (let doc of docs) { + if (!doc.contextId || (context.idsAsHex && !re.test(doc.contextId))) { + contextColl.removeByExample(doc); + } + } + }); +} + +function v5_9_1() { + let hashes = variablesColl.document("VERIFICATIONS_HASHES").hashes; + new_hashes = {}; + for (let item of hashes) { + new_hashes[item["block"]] = item; + delete item["block"]; + } + variablesColl.update("VERIFICATIONS_HASHES", { hashes: new_hashes }); +} + +function v5_9_2() { + let hashes = variablesColl.document("VERIFICATIONS_HASHES").hashes; + variablesColl.update("VERIFICATIONS_HASHES", { + hashes: JSON.stringify(hashes), + }); +} + +function v5_9_8() { + console.log("removing invalid contextIds form contexts' collection"); + const re = new RegExp(/^0[xX][A-Fa-f0-9]+$/); + + const contextsColl = arango._collection("contexts"); + contextsColl + .all() + .toArray() + .map((context) => { + console.log(`Context: ${context._key}`); + const contextColl = arango._collection(context.collection); + if (!contextColl) { + return; + } + const docs = contextColl.all().toArray(); + for (let doc of docs) { + if ( + !doc.contextId || + (context.idsAsHex && !db.isEthereumAddress(doc.contextId)) + ) { + console.log(`Remove invalid contextId: ${doc.contextId}`); + contextColl.removeByExample(doc); + } else if ( + context.idsAsHex && + db.isEthereumAddress(doc.contextId) && + doc.contextId != doc.contextId.toLowerCase() + ) { + console.log(`Update checksum contextId: ${doc.contextId}`); + contextColl.update(doc, { contextId: doc.contextId.toLowerCase() }); + } + } + }); +} + +const upgrades = [ + "v5", + "v5_3", + "v5_5", + "v5_6", + "v5_6_1", + "v5_7", + "v5_8", + "v5_9", + "v5_9_1", + "v5_9_2", + "v5_9_8", +]; + +function initdb() { + createCollections(); + createIndexes(); + removeDeprecatedCollections(); + initializeVariables(); + let index; + if (variablesColl.exists("LAST_DB_UPGRADE")) { + upgrade = variablesColl.document("LAST_DB_UPGRADE").value; + index = upgrades.indexOf(upgrade) + 1; + } else { + index = 0; + } + while (upgrades[index]) { + eval(upgrades[index])(); + variablesColl.update("LAST_DB_UPGRADE", { value: upgrades[index] }); + index += 1; + } +} + +initdb(); diff --git a/web_services/foxx/brightid5/manifest.json b/web_services/foxx/brightid5/manifest.json new file mode 100644 index 00000000..7b19c1ab --- /dev/null +++ b/web_services/foxx/brightid5/manifest.json @@ -0,0 +1,43 @@ +{ + "main": "index.js", + "name": "BrightID-Node", + "description": "Read and update the anonymous social graph stored on BrightID nodes.", + "license": "ISC", + "version": "5.18.0", + "tests": ["tests/*.js"], + "scripts": { + "setup": "initdb.js" + }, + "configuration": { + "seed": { + "description": "A password; is used for generating privateKey, ethPrivateKey, consensusSenderPrivateKey, and wISchnorrPassword if they haven't set (string)", + "type": "string", + "required": false + }, + "privateKey": { + "description": "Private key of this server node; used for signing verifications (base64 encoded)", + "type": "string", + "required": false + }, + "ethPrivateKey": { + "description": "Ethereum private key of this server node; used for signing verifications (hex representation without 0x)", + "type": "string", + "required": false + }, + "operationsTimeWindow": { + "description": "The time in seconds after which the limits for sending operations will reset", + "type": "int", + "required": false + }, + "operationsLimit": { + "description": "Maximum number of operations each verified user can send in configured time window", + "type": "int", + "required": false + }, + "appsOperationsLimit": { + "description": "Maximum number of operations each app can send in configured time window", + "type": "int", + "required": false + } + } +} diff --git a/web_services/foxx/brightid5/manifest_apply.json b/web_services/foxx/brightid5/manifest_apply.json new file mode 100644 index 00000000..cbc2a23f --- /dev/null +++ b/web_services/foxx/brightid5/manifest_apply.json @@ -0,0 +1,9 @@ +{ + "main": "apply.js", + "name": "apply", + "description": "Allows BrightID consensus module to apply operations to the database.", + "version": "5.18.0", + "scripts": { + "setup": "initdb.js" + } +} diff --git a/web_services/foxx/brightid5/operations.js b/web_services/foxx/brightid5/operations.js new file mode 100755 index 00000000..4fc56a9b --- /dev/null +++ b/web_services/foxx/brightid5/operations.js @@ -0,0 +1,331 @@ +const stringify = require("fast-json-stable-stringify"); +const db = require("./db"); +const { db: arango, query } = require("@arangodb"); +var CryptoJS = require("crypto-js"); +const nacl = require("tweetnacl"); +const { + strToUint8Array, + b64ToUint8Array, + urlSafeB64ToB64, + hash, +} = require("./encoding"); +const errors = require("./errors"); + +const usersColl = arango._collection("users"); +const operationCountersColl = arango._collection("operationCounters"); +const sponsorshipsColl = arango._collection("sponsorships"); + +const TIME_FUDGE = 60 * 60 * 1000; // timestamp can be this far in the future (milliseconds) to accommodate client/server clock differences + +const verifyUserSig = function (message, id, sig) { + const user = db.loadUser(id); + // When "Add Connection" is called on a user that is not created yet + // signingKey can be calculated from user's brightid + let signingKeys = user ? user.signingKeys : [urlSafeB64ToB64(id)]; + for (signingKey of signingKeys) { + if ( + nacl.sign.detached.verify( + strToUint8Array(message), + b64ToUint8Array(sig), + b64ToUint8Array(signingKey) + ) + ) { + return signingKey; + } + } + throw new errors.InvalidSignatureError(); +}; + +const verifyAppSig = function (message, app, sig) { + app = db.getApp(app); + if ( + !nacl.sign.detached.verify( + strToUint8Array(message), + b64ToUint8Array(sig), + b64ToUint8Array(app.sponsorPublicKey) + ) + ) { + throw new errors.InvalidSignatureError(); + } +}; + +const senderAttrs = { + Connect: ["id1"], + "Add Connection": ["id1", "id2"], + "Remove Connection": ["id1"], + "Add Group": ["id1"], + "Remove Group": ["id"], + "Add Membership": ["id"], + "Remove Membership": ["id"], + "Set Trusted Connections": ["id"], + "Set Signing Key": ["id"], + Sponsor: ["app","id"], //! deprecated app is not used anymore + "Spend Sponsorship": ["app"], //! deprecated + "Link ContextId": ["id"], + Invite: ["inviter"], + Dismiss: ["dismisser"], + "Add Admin": ["id"], + "Add Signing Key": ["id"], + "Remove Signing Key": ["id"], + "Remove All Signing Keys": ["id"], + "Update Group": ["id"], +}; + +function checkLimits(op, timeWindow, limit) { + let expireDate; + const now = Date.now(); + const senders = senderAttrs[op.name].map((attr) => op[attr]); + for (let sender of senders) { + // these condition structure is applying: + // 1) a bucket for a verified user + // 2) a bucket for children of a verified user + // 3) a bucket for all non-verified users without parent + // 4) a bucket for an app + // where parent is the first verified user that make connection with the user + + //! deprecated we don't accept spend sponsorship anymore + if (op["name"] == "Spend Sponsorship") { + const app = db.getApp(op.app); + const contextId = app.idsAsHex ? op.contextId.toLowerCase() : op.contextId; + const sponsorship = sponsorshipsColl.firstExample({ + appId: contextId, + }); + if (!sponsorship) { + sender = "shared_apps"; + } else if (sponsorship.spendRequested) { + throw new errors.SpendRequestedBeforeError(); + } else if (!sponsorship.appHasAuthorized) { + sender = "shared_apps"; + } + } + //! deprecated we don't accept sponsor anymore + if (!["Sponsor", "Spend Sponsorship"].includes(op["name"])) { + if (!usersColl.exists(sender)) { + // this happens when operation is "Add Connection" and one/both sides don't exist + sender = "shared"; + } else { + const user = usersColl.document(sender); + const verifications = db + .userVerifications(user._key) + .map((v) => v.name); + verified = verifications && verifications.includes("BrightID"); + if (!verified && user.parent) { + // this happens when user is not verified but has a verified connection + sender = `shared_${user.parent}`; + } else if (!verified && !user.parent) { + // this happens when user is not verified and does not have a verified connection + sender = "shared"; + } + } + } + const c = operationCountersColl.firstExample({ _key: sender }); + let counter = c ? c.counter : 0; + expireDate = c ? c.expireDate : Math.ceil(now / 1000 + timeWindow / 1000); + counter += 1; + query` + UPSERT { _key: ${sender} } + INSERT { + _key: ${sender}, + counter: ${counter}, + expireDate: ${expireDate}, + } + UPDATE { counter: ${counter} } + IN operationCounters + `; + + if (counter <= limit) { + // if operation has multiple senders, this check will be passed + // even if one of the senders did not reach limit yet + return; + } + } + + throw new errors.TooManyOperationsError( + senders, + expireDate * 1000 - now, + timeWindow, + limit + ); +} + +const signerAndSigs = { + "Remove Connection": ["id1", "sig1"], + "Add Group": ["id1", "sig1"], + "Remove Group": ["id", "sig"], + "Add Membership": ["id", "sig"], + "Remove Membership": ["id", "sig"], + "Set Trusted Connections": ["id", "sig"], + "Link ContextId": ["id", "sig"], + Invite: ["inviter", "sig"], + Dismiss: ["dismisser", "sig"], + "Add Admin": ["id", "sig"], + "Update Group": ["id", "sig"], + "Add Signing Key": ["id", "sig"], + "Remove Signing Key": ["id", "sig"], + "Remove All Signing Keys": ["id", "sig"], +}; + +function verify(op) { + if (op.v != 5) { + throw new errors.InvalidOperationVersionError(op.v); + } + if (op.timestamp > Date.now() + TIME_FUDGE) { + throw new errors.InvalidOperationTimestampError(op.timestamp); + } + + let message = getMessage(op); + if(op.name == "Sponsor" && op.id){ + verifyUserSig(message, op.id, op.sig); + } + //! deprecated + else if (op.name == "Sponsor" && op.contextId) { + verifyAppSig(message, op.app, op.sig); + // prevent apps from sending duplicate sponsor requests + if (db.sponsorRequestedRecently(op)) { + throw new errors.SponsorRequestedRecently(); + } else if (db.isSponsoredByContextId(op)) { + throw new errors.SponsoredBeforeError(); + } + } else if (op.name == "Spend Sponsorship") { + // there is no sig on this operation + //! deprecated we don't accept spend sponsorship anymore + return; + } else if (op.name == "Set Signing Key") { + const recoveryConnections = db.getRecoveryConnections(op.id); + if ( + op.id1 == op.id2 || + !recoveryConnections.includes(op.id1) || + !recoveryConnections.includes(op.id2) + ) { + throw new errors.NotRecoveryConnectionsError(); + } + verifyUserSig(message, op.id1, op.sig1); + verifyUserSig(message, op.id2, op.sig2); + } else if (op.name == "Add Connection") { + verifyUserSig(message, op.id1, op.sig1); + verifyUserSig(message, op.id2, op.sig2); + } else if (op.name == "Connect") { + verifyUserSig(message, op.id1, op.sig1); + if (op.requestProof) { + verifyUserSig(op.id2 + "|" + op.timestamp, op.id2, op.requestProof); + } + } else { + const [signerAttr, sigAttr] = signerAndSigs[op.name]; + const signer = op[signerAttr]; + const sig = op[sigAttr]; + verifyUserSig(message, signer, sig); + } + if (hash(message) != op.hash) { + throw new errors.InvalidOperationHashError(); + } +} + +function apply(op) { + if (op["name"] == "Remove All Signing Keys") { + // verifyUserSig returns the key that used to sign the op + // removeAllSigningKeys remove all keys except this one + const signingKey = verifyUserSig(getMessage(op), op.id, op.sig); + op.timestamp = op.blockTime; + return db.removeAllSigningKeys(op.id, signingKey, op.timestamp); + } + + // set the block time instead of user timestamp + op.timestamp = op.blockTime; + if (op["name"] == "Connect") { + return db.connect(op); + } else if (op["name"] == "Add Connection") { + // this operation is deprecated and will be removed on v6 + // use "Connect" instead + return db.addConnection(op.id1, op.id2, op.timestamp); + } else if (op["name"] == "Remove Connection") { + // this operation is deprecated and will be removed on v6 + // use "Connect" instead + return db.removeConnection(op.id1, op.id2, op.reason, op.timestamp); + } else if (op["name"] == "Add Group") { + return db.createGroup( + op.group, + op.id1, + op.id2, + op.inviteData2, + op.id3, + op.inviteData3, + op.url, + op.type, + op.timestamp + ); + } else if (op["name"] == "Remove Group") { + return db.deleteGroup(op.group, op.id, op.timestamp); + } else if (op["name"] == "Add Membership") { + return db.addMembership(op.group, op.id, op.timestamp); + } else if (op["name"] == "Remove Membership") { + return db.deleteMembership(op.group, op.id, op.timestamp); + } else if (op["name"] == "Set Trusted Connections") { + // this operation is deprecated and will be removed on v6 + // use "Connect" instead + return db.setRecoveryConnections(op.trusted, op.id, op.timestamp); + } else if (op["name"] == "Set Signing Key") { + return db.setSigningKey(op.signingKey, op.id, op.timestamp); + //!deprecated we don't accept sponsor anymore + } else if (["Sponsor", "Spend Sponsorship"].includes(op["name"])) { + return db.sponsor(op); + } else if (op["name"] == "Link ContextId") { + return db.linkContextId(op.id, op.context, op.contextId, op.timestamp); + } else if (op["name"] == "Invite") { + return db.invite(op.inviter, op.invitee, op.group, op.data, op.timestamp); + } else if (op["name"] == "Dismiss") { + return db.dismiss(op.dismisser, op.dismissee, op.group, op.timestamp); + } else if (op["name"] == "Add Admin") { + return db.addAdmin(op.id, op.admin, op.group, op.timestamp); + } else if (op["name"] == "Add Signing Key") { + return db.addSigningKey(op.id, op.signingKey, op.timestamp); + } else if (op["name"] == "Remove Signing Key") { + return db.removeSigningKey(op.id, op.signingKey, op.timestamp); + } else if (op["name"] == "Update Group") { + return db.updateGroup(op.id, op.group, op.url, op.timestamp); + } else { + throw new errors.InvalidOperationNameError(op["name"]); + } +} + +function encrypt(op) { + const { linkAESKey } = db.getContext(op.context); + const jsonStr = stringify({ id: op.id, contextId: op.contextId }); + op.encrypted = CryptoJS.AES.encrypt(jsonStr, linkAESKey).toString(); + delete op.id; + delete op.contextId; +} + +function getMessage(op) { + const signedOp = {}; + for (let k in op) { + if (["sig", "sig1", "sig2", "hash", "blockTime"].includes(k)) { + continue; + } else if (op.name == "Set Signing Key" && ["id1", "id2"].includes(k)) { + continue; + } + signedOp[k] = op[k]; + } + return stringify(signedOp); +} + +function decrypt(op) { + const { linkAESKey } = db.getContext(op.context); + const decrypted = CryptoJS.AES.decrypt(op.encrypted, linkAESKey).toString( + CryptoJS.enc.Utf8 + ); + const json = JSON.parse(decrypted); + delete op.encrypted; + op.id = json.id; + op.contextId = json.contextId; + op.hash = hash(getMessage(op)); +} + +module.exports = { + verify, + apply, + encrypt, + decrypt, + verifyUserSig, + checkLimits, + getMessage, +}; diff --git a/web_services/foxx/brightid5/package-lock.json b/web_services/foxx/brightid5/package-lock.json new file mode 100644 index 00000000..201c893f --- /dev/null +++ b/web_services/foxx/brightid5/package-lock.json @@ -0,0 +1,271 @@ +{ + "name": "brightid-foxx", + "version": "5.18.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "brightid-foxx", + "version": "5.18.0", + "dependencies": { + "base64-js": "^1.3.0", + "crypto-js": "^3.1.9-1", + "expr-eval": "^2.0.2", + "fast-json-stable-stringify": "^2.1.0", + "keccak": "^3.0.0", + "lodash": "^4.17.11", + "secp256k1": "^4.0.0", + "tweetnacl": "^1.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz", + "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==" + }, + "node_modules/bn.js": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==" + }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=" + }, + "node_modules/crypto-js": { + "version": "3.1.9-1", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.1.9-1.tgz", + "integrity": "sha1-/aGedh/Ad+Af+/3G6f38WeiAbNg=" + }, + "node_modules/elliptic": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz", + "integrity": "sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw==", + "dependencies": { + "bn.js": "^4.4.0", + "brorand": "^1.0.1", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.0" + } + }, + "node_modules/expr-eval": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/expr-eval/-/expr-eval-2.0.2.tgz", + "integrity": "sha512-4EMSHGOPSwAfBiibw3ndnP0AvjDWLsMvGOvWEZ2F96IGk0bIVdjQisOHxReSkE13mHcfbuCiXw+G4y0zv6N8Eg==" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/keccak": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/keccak/-/keccak-3.0.0.tgz", + "integrity": "sha512-/4h4FIfFEpTEuySXi/nVFM5rqSKPnnhI7cL4K3MFSwoI3VyM7AhPSq3SsysARtnEBEeIKMBUWD8cTh9nHE8AkA==", + "hasInstallScript": true, + "dependencies": { + "node-addon-api": "^2.0.0", + "node-gyp-build": "^4.2.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/lodash": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==" + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=" + }, + "node_modules/node-addon-api": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.0.tgz", + "integrity": "sha512-ASCL5U13as7HhOExbT6OlWJJUV/lLzL2voOSP1UVehpRD8FbSrSDjfScK/KwAvVTI5AS6r4VwbOMlIqtvRidnA==" + }, + "node_modules/node-gyp-build": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.2.0.tgz", + "integrity": "sha512-4oiumOLhCDU9Rronz8PZ5S4IvT39H5+JEv/hps9V8s7RSLhsac0TCP78ulnHXOo8X1wdpPiTayGlM1jr4IbnaQ==", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/secp256k1": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-4.0.0.tgz", + "integrity": "sha512-0w0zse+Iku13O58SVE9/DhyCKWNsKb+n/vMqLOGICgSqxWuXZs+eajBf9uVOgk5QfNvTY/mx0QSqYxkcz802dw==", + "hasInstallScript": true, + "dependencies": { + "elliptic": "^6.5.2", + "node-addon-api": "^2.0.0", + "node-gyp-build": "^4.2.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/tweetnacl": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.0.tgz", + "integrity": "sha1-cT2LgY2kIGh0C/aDhtBHnmb8ins=" + } + }, + "dependencies": { + "base64-js": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz", + "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==" + }, + "bn.js": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==" + }, + "brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=" + }, + "crypto-js": { + "version": "3.1.9-1", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.1.9-1.tgz", + "integrity": "sha1-/aGedh/Ad+Af+/3G6f38WeiAbNg=" + }, + "elliptic": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz", + "integrity": "sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw==", + "requires": { + "bn.js": "^4.4.0", + "brorand": "^1.0.1", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.0" + } + }, + "expr-eval": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/expr-eval/-/expr-eval-2.0.2.tgz", + "integrity": "sha512-4EMSHGOPSwAfBiibw3ndnP0AvjDWLsMvGOvWEZ2F96IGk0bIVdjQisOHxReSkE13mHcfbuCiXw+G4y0zv6N8Eg==" + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + "hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "requires": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=", + "requires": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "keccak": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/keccak/-/keccak-3.0.0.tgz", + "integrity": "sha512-/4h4FIfFEpTEuySXi/nVFM5rqSKPnnhI7cL4K3MFSwoI3VyM7AhPSq3SsysARtnEBEeIKMBUWD8cTh9nHE8AkA==", + "requires": { + "node-addon-api": "^2.0.0", + "node-gyp-build": "^4.2.0" + } + }, + "lodash": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==" + }, + "minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, + "minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=" + }, + "node-addon-api": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.0.tgz", + "integrity": "sha512-ASCL5U13as7HhOExbT6OlWJJUV/lLzL2voOSP1UVehpRD8FbSrSDjfScK/KwAvVTI5AS6r4VwbOMlIqtvRidnA==" + }, + "node-gyp-build": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.2.0.tgz", + "integrity": "sha512-4oiumOLhCDU9Rronz8PZ5S4IvT39H5+JEv/hps9V8s7RSLhsac0TCP78ulnHXOo8X1wdpPiTayGlM1jr4IbnaQ==" + }, + "secp256k1": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-4.0.0.tgz", + "integrity": "sha512-0w0zse+Iku13O58SVE9/DhyCKWNsKb+n/vMqLOGICgSqxWuXZs+eajBf9uVOgk5QfNvTY/mx0QSqYxkcz802dw==", + "requires": { + "elliptic": "^6.5.2", + "node-addon-api": "^2.0.0", + "node-gyp-build": "^4.2.0" + } + }, + "tweetnacl": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.0.tgz", + "integrity": "sha1-cT2LgY2kIGh0C/aDhtBHnmb8ins=" + } + } +} diff --git a/web_services/foxx/brightid5/package.json b/web_services/foxx/brightid5/package.json new file mode 100755 index 00000000..3e53be4f --- /dev/null +++ b/web_services/foxx/brightid5/package.json @@ -0,0 +1,15 @@ +{ + "name": "brightid-foxx", + "description": "Foxx service for managing BrightID connections", + "version": "5.18.0", + "dependencies": { + "base64-js": "^1.3.0", + "crypto-js": "^3.1.9-1", + "expr-eval": "^2.0.2", + "fast-json-stable-stringify": "^2.1.0", + "keccak": "^3.0.0", + "lodash": "^4.17.11", + "secp256k1": "^4.0.0", + "tweetnacl": "^1.0.0" + } +} diff --git a/web_services/foxx/brightid5/schemas.js b/web_services/foxx/brightid5/schemas.js new file mode 100755 index 00000000..8494630b --- /dev/null +++ b/web_services/foxx/brightid5/schemas.js @@ -0,0 +1,952 @@ +const joi = require("joi"); + +// lowest-level schemas +var schemas = { + score: joi.number().min(0).max(5).default(0), + timestamp: joi.number().integer(), +}; + +const operations = { + Connect: { + id1: joi + .string() + .required() + .description("brightid of the user making the directed connection"), + id2: joi + .string() + .required() + .description("brightid of the target of the directed connection"), + sig1: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id1" + ), + level: joi + .string() + .valid("reported", "suspicious", "just met", "already known", "recovery") + .required() + .description("level of confidence"), + reportReason: joi + .string() + .valid("spammer", "fake", "duplicate", "deceased", "replaced", "other") + .description( + "for reported level, the reason for reporting the user specificed by id2" + ), + replacedWith: joi + .string() + .description( + "for reported as replaced, the new brightid of the replaced account" + ), + requestProof: joi + .string() + .description( + 'brightid + "|" + timestamp signed by the reported user to prove that he requested the connection' + ), + }, + "Add Connection": { + id1: joi + .string() + .required() + .description("brightid of the first user making the connection"), + id2: joi + .string() + .required() + .description("brightid of the second user making the connection"), + sig1: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id1" + ), + sig2: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id2" + ), + }, + "Remove Connection": { + id1: joi + .string() + .required() + .description("brightid of the user removing the connection"), + id2: joi + .string() + .required() + .description( + "brightid of the second user that the connection with is being removed" + ), + reason: joi + .string() + .valid("fake", "duplicate", "deceased") + .required() + .description( + "the reason for removing connection specificed by the user represented by id1" + ), + sig1: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id1" + ), + }, + "Add Group": { + group: joi.string().required().description("the unique id of the group"), + id1: joi.string().required().description("brightid of the first founder"), + id2: joi.string().required().description("brightid of the second founder"), + id3: joi.string().required().description("brightid of the third founder"), + inviteData2: joi + .string() + .required() + .description( + "the group AES key encrypted for signingKey of the user represented by id2" + ), + inviteData3: joi + .string() + .required() + .description( + "the group AES key encrypted for signingKey of the user represented by id3" + ), + url: joi + .string() + .required() + .description( + "the url that group data (profile image and name) encrypted by group AES key can be fetched from" + ), + type: joi + .string() + .valid("general", "primary") + .required() + .description("type of the group"), + sig1: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the creator of group represented by id1" + ), + }, + "Remove Group": { + id: joi + .string() + .required() + .description("brightid of the group admin who want to remove the group"), + group: joi.string().required().description("the unique id of the group"), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id1" + ), + }, + "Add Membership": { + id: joi + .string() + .required() + .description("brightid of the user wants to join the group"), + group: joi + .string() + .required() + .description( + "the unique id of the group that the user represented by id wants to join" + ), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id" + ), + }, + "Remove Membership": { + id: joi + .string() + .required() + .description("brightid of the user wants to leave the group"), + group: joi + .string() + .required() + .description( + "the unique id of the group that the user represented by id wants to leave" + ), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id" + ), + }, + "Set Trusted Connections": { + id: joi + .string() + .required() + .description("brightid of the user who is setting recovery connections"), + trusted: joi + .array() + .items(joi.string()) + .required() + .description("brightid list of recovery connections"), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id" + ), + }, + // this operation should be renamed to "Social Recovery" in v6 + "Set Signing Key": { + id: joi + .string() + .required() + .description( + "brightid of the user who is resetting signingKeys by social recovery" + ), + signingKey: joi + .string() + .required() + .description( + "the public key of the new key pair that user will use to sign operations with" + ), + id1: joi + .string() + .required() + .description( + "brightid of a recovery connection of the user represented by id" + ), + id2: joi + .string() + .required() + .description( + "brightid of a recovery connection of the user represented by id" + ), + sig1: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the recovery connection represented by id1" + ), + sig2: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the recovery connection represented by id2" + ), + }, + "Link ContextId": { + id: joi + .string() + .description( + "brightid of the user who is linking his/her brightid to a context id" + ), + contextId: joi + .string() + .description( + "the unique id of the user represented by brightid in the specific context" + ), + encrypted: joi + .string() + .description( + "the json representation of `{id: id, contextId: contextId}` encrypted using an AES key shared between all nodes manage linking brightids to contextIds for a specific context. This field is not sent by clients and will be replaced by `id` and `contextId` fields before sending operation to blockchain to keep the relation of brightids to contextIds private." + ), + context: joi + .string() + .required() + .description( + "the context name in which the user represented by brightid is linking context id with his/her brightid" + ), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id" + ), + }, + Sponsor: { + contextId: joi + .string() + .description( + "the contextId for the user that is being sponsored by context" + ), + id: joi + .string() + .description("brightid of the user who is requesting sponsorship"), + app: joi + .string() + .required() + .description("the app key that user is being sponsored by"), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the private key shared between context owners and trusted node operators which enable them to spend sponsorships assigned to the context" + ), + }, + "Spend Sponsorship": { + contextId: joi + .string() + .required() + .description("the contextId that is being sponsored"), + app: joi + .string() + .required() + .description("the app key that user is being sponsored by"), + }, + Invite: { + inviter: joi + .string() + .required() + .description( + "brightid of the user who has admin rights in the group and can invite others to the group" + ), + invitee: joi + .string() + .required() + .description("brightid of the user whom is invited to the group"), + group: joi + .string() + .required() + .description( + "the unique id of the group that invitee is being invited to" + ), + data: joi + .string() + .required() + .description("the group AES key encrypted for signingKey of the invitee"), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the inviter" + ), + }, + Dismiss: { + dismisser: joi + .string() + .required() + .description( + "brightid of the user who has admin rights in the group and can dismiss others from the group" + ), + dismissee: joi + .string() + .required() + .description("brightid of the user whom is dismissed from the group"), + group: joi + .string() + .required() + .description( + "the unique id of the group that dismissee is being dismissed from" + ), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the dismisser" + ), + }, + "Add Admin": { + id: joi + .string() + .required() + .description("brightid of one of the current admins of the group"), + admin: joi + .string() + .required() + .description( + "brightid of the member whom is being granted administratorship of the group" + ), + group: joi.string().required().description("the unique id of the group"), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the admin user represented by id" + ), + }, + "Update Group": { + id: joi + .string() + .required() + .description("brightid of one of the admins of the group"), + group: joi.string().required().description("the unique id of the group"), + url: joi + .string() + .required() + .description( + "the new url that group data (profile image and name) encrypted by group AES key can be fetched from" + ), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id" + ), + }, + "Add Signing Key": { + id: joi + .string() + .required() + .description("brightid of the user who is adding new signingKey"), + signingKey: joi + .string() + .required() + .description( + "the public key of the new key pair that user can sign operations with" + ), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id" + ), + }, + "Remove Signing Key": { + id: joi + .string() + .required() + .description("brightid of the user who is removing the signingKey"), + signingKey: joi + .string() + .required() + .description("the signingKey that is being removed"), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id" + ), + }, + "Remove All Signing Keys": { + id: joi + .string() + .required() + .description( + "brightid of the user who is removing all the signingKeys except the one that used to sign this operation" + ), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id" + ), + }, +}; + +Object.keys(operations).forEach((name) => { + operations[name] = Object.assign( + { + name: joi.string().valid(name).required().description("operation name"), + }, + operations[name], + { + timestamp: joi + .number() + .required() + .description("milliseconds since epoch when the operation created"), + v: joi.number().required().valid(5).description("version of API"), + } + ); +}); + +// extend lower-level schemas with higher-level schemas +schemas = Object.assign( + { + user: joi.object({ + id: joi.string().required().description("the user id"), + // this will be replaced by signingKeys on v6 + signingKey: joi + .string() + .required() + .description("the first signingKey of the user"), + score: schemas.score, + level: joi + .string() + .required() + .description("the confidence level set on this user"), + verifications: joi.array().items(joi.string()), + hasPrimaryGroup: joi + .boolean() + .description("true if user has primary group"), + trusted: joi + .array() + .items(joi.string()) + .description("list of recovery connections of the user"), + flaggers: joi + .object() + .description( + "an object containing ids of flaggers as key and reason as value" + ), + createdAt: schemas.timestamp + .required() + .description("the user creation timestamp"), + }), + connection: joi.object({ + id: joi.string().required().description("the brightid of the connection"), + level: joi.string().required().description("the level of the connection"), + timestamp: schemas.timestamp + .required() + .description("the timestamp of the connection"), + }), + groupBase: joi.object({ + id: joi.string().required().description("unique identifier of the group"), + members: joi + .array() + .items(joi.string()) + .required() + .description("brightids of group members"), + type: joi + .string() + .required() + .description('type of group which is "primary" or "general"'), + founders: joi + .array() + .items(joi.string()) + .required() + .description("brightids of group founders"), + admins: joi + .array() + .items(joi.string()) + .required() + .description("brightids of group admins"), + isNew: joi + .boolean() + .required() + .description( + "true if some of founders did not join the group yet and group is still in founding stage" + ), + // score on group is deprecated and will be removed on v6 + score: schemas.score, + url: joi + .string() + .required() + .description("url of encrypted group data (name and photo)"), + timestamp: schemas.timestamp + .required() + .description("group creation timestamp"), + }), + app: joi.object({ + id: joi.string().required().description("unique app id"), + name: joi.string().required().description("app name"), + context: joi.string().required().description("app context"), + verification: joi + .string() + .required() + .description("verification required for using the app"), + verificationUrl: joi + .string() + .required() + .description("the url to PUT a verification with /:id"), + logo: joi.string().description("app logo (base64 encoded image)"), + url: joi.string().description("the base url for the app"), + assignedSponsorships: joi + .number() + .integer() + .description("number of assigned sponsorships"), + unusedSponsorships: joi + .number() + .integer() + .description("number of unused sponsorships"), + testing: joi + .boolean() + .required() + .description("true if the app is in the testing mode"), + soulbound: joi + .boolean() + .required() + .description("true if the app uses soulbound standard"), + soulboundMessage: joi + .string() + .required() + .description("a static message to be signed at linking time by the context id"), + }), + }, + schemas +); + +schemas = Object.assign( + { + operation: joi + .alternatives() + .try( + Object.keys(operations).map((name) => + name === 'Sponsor' ? + joi.object(operations[name]).label(name).xor('id', 'contextId') : + joi.object(operations[name]).label(name) + ) + ) + .description( + "Send operations to idchain to be applied to BrightID nodes' databases after consensus" + ), + }, + schemas +); + +schemas = Object.assign( + { + group: schemas.groupBase.keys({ + joined: schemas.timestamp + .required() + .description("timestamp when the user joined"), + }), + + invite: schemas.groupBase.keys({ + inviteId: joi + .string() + .required() + .description("unique identifier of invite"), + invited: schemas.timestamp + .required() + .description("timestamp when the user was invited"), + inviter: joi.string().required().description("brightid of inviter"), + data: joi + .string() + .required() + .description( + "encrypted version of the AES key that group name and photo uploaded to `url` encrypted with" + + "invitee should first decrypt this data with his/her signingKey and then fetch data in `url` and decrypt that using the AES key" + ), + }), + }, + schemas +); + +// extend lower-level schemas with higher-level schemas +schemas = Object.assign( + { + operationPostResponse: joi.object({ + data: joi.object({ + hash: joi + .string() + .required() + .description( + "sha256 hash of the operation message used for generating signature" + ), + }), + }), + + userGetResponse: joi.object({ + data: joi.object({ + score: schemas.score, + createdAt: schemas.timestamp.required(), + groups: joi.array().items(schemas.group), + invites: joi.array().items(schemas.invite), + connections: joi.array().items(schemas.user), + verifications: joi.array().items(joi.string()), + isSponsored: joi.boolean(), + trusted: joi.array().items(joi.string()), + flaggers: joi + .object() + .description( + "an object containing ids of flaggers as key and reason as value" + ), + signingKeys: joi + .array() + .items(joi.string()) + .required() + .description( + "list of signing keys that user can sign operations with" + ), + }), + }), + + userConnectionsGetResponse: joi.object({ + data: joi.object({ + connections: joi.array().items(schemas.connection), + }), + }), + + userVerificationsGetResponse: joi.object({ + data: joi.object({ + verifications: joi.array().items(joi.object()), + }), + }), + + userProfileGetResponse: joi.object({ + data: joi.object({ + connectionsNum: joi + .number() + .integer() + .required() + .description( + "number of connections with already known or recovery level" + ), + groupsNum: joi + .number() + .integer() + .required() + .description("number of groups"), + mutualConnections: joi + .array() + .items(joi.string()) + .required() + .description("brightids of mutual connections"), + mutualGroups: joi + .array() + .items(joi.string()) + .required() + .description("ids of mutual groups"), + connectedAt: schemas.timestamp + .required() + .description("timestamp of last connection"), + createdAt: schemas.timestamp + .required() + .description("creation time of user specified by id"), + reports: joi + .array() + .items( + joi.object({ + id: joi.string().required().description("brightid of reporter"), + reportReason: joi + .string() + .required() + .description("the reason of reporting"), + }) + ) + .description("list of reports for the user specified by id"), + verifications: joi + .array() + .items(joi.object()) + .required() + .description( + "list of verification objects user has with properties each verification has" + ), + signingKeys: joi + .array() + .items(joi.string()) + .required() + .description( + "list of signing keys that user can sign operations with" + ), + }), + }), + + operationGetResponse: joi.object({ + data: joi.object({ + state: joi + .string() + .valid("init", "sent", "applied", "failed") + .description("state of operation"), + result: joi + .string() + .description( + "result of operation after being applied. If operation is failed this field contain the reason." + ), + }), + }), + + verificationGetResponse: joi.object({ + data: joi.object({ + unique: joi.string().description("true if user is verified for an app"), + app: joi.string().description("the key of app"), + context: joi.string().description("the context name"), + contextIds: joi + .array() + .items(joi.string()) + .description( + "list of all contextIds this user linked from most recent to oldest including current active contextId as first member" + ), + timestamp: schemas.timestamp.description( + "timestamp of the verification if a timestamp was requested by including a 'timestamp' parameter" + ), + sig: joi + .string() + .description("verification message signed by the node"), + publicKey: joi.string().description("the node's public key"), + }), + }), + + allVerificationsGetResponse: joi.object({ + data: joi.object({ + contextIds: joi + .array() + .items(joi.string()) + .description("an array of contextIds"), + }), + }), + + ipGetResponse: joi.object({ + data: joi.object({ + ip: joi.string().description("IPv4 address in dot-decimal notation."), + }), + }), + + appGetResponse: joi.object({ + data: schemas.app, + }), + + allAppsGetResponse: joi.object({ + data: joi.object({ + apps: joi.array().items(schemas.app), + }), + }), + + stateGetResponse: joi.object({ + data: joi.object({ + lastProcessedBlock: joi + .number() + .integer() + .required() + .description("last block that consensus receiver service processed"), + verificationsBlock: joi + .number() + .integer() + .required() + .description( + "the block that scorer service updated verifications based on operations got applied before that block" + ), + initOp: joi + .number() + .integer() + .required() + .description("number of operations in the init state"), + sentOp: joi + .number() + .integer() + .required() + .description("number of operations in the sent state"), + verificationsHashes: joi + .array() + .items(joi.object()) + .required() + .description("different verifications' hashes for last 2 snapshots"), + ethSigningAddress: joi + .string() + .required() + .description( + "the ethereum address of this node; used for signing verifications" + ), + naclSigningKey: joi + .string() + .required() + .description( + "nacl signing key of this node; used for signing verifications" + ), + consensusSenderAddress: joi + .string() + .required() + .description( + "the ethereum address of consensus sender service of this node; used for sending operations" + ), + version: joi.string().required().description("version of this node"), + }), + }), + + contextDumpGetResponse: joi.object({ + data: joi.object({ + collection: joi + .string() + .required() + .description( + "the collection name used to store contextIds linked under the context" + ), + idsAsHex: joi + .boolean() + .required() + .description("true if contextIds are hex strings"), + linkAESKey: joi + .string() + .required() + .description( + "the AES key used to encrypt links before sending to IDChain and decrypt after receiving them" + ), + contextIds: joi + .array() + .required() + .items(joi.string()) + .description("list of all contextIds linked under the context"), + }), + }), + + groupGetResponse: joi.object({ + data: joi.object({ + members: joi + .array() + .items(joi.string()) + .required() + .description("brightids of members of the group"), + invites: joi + .array() + .items( + joi.object({ + inviter: joi + .string() + .required() + .description("brightid of inviter"), + invitee: joi + .string() + .required() + .description("brightid of invitee"), + id: joi.string().required().description("unique id of invite"), + data: joi + .string() + .required() + .description("AES key of group encrypted for invitee"), + timestamp: joi + .number() + .required() + .description("timestamp of invite"), + }) + ) + .required(), + eligibles: joi + .array() + .items(joi.string()) + .required() + .description( + "brightids of the users that are eligible to join the group" + ), + admins: joi + .array() + .items(joi.string()) + .required() + .description("brightids of admins of the group"), + founders: joi + .array() + .items(joi.string()) + .required() + .description("brightids of founders of the group"), + isNew: joi.boolean().required().description("true if group is new"), + seed: joi.boolean().required().description("true if group is Seed"), + region: joi.string().description("region of the group"), + type: joi.string().required().description("type of the group"), + url: joi.string().required().description("url of the group"), + info: joi + .string() + .description("URL of a documnet that contains info about the group"), + timestamp: joi + .number() + .required() + .description("the group creation timestamp"), + }), + }), + + sponsorshipGetResponse: joi.object({ + data: joi.object({ + app: joi + .string() + .required() + .description("the app key that user is being sponsored by"), + appHasAuthorized: joi + .boolean() + .required() + .description( + "true if the app authorized the node to use sponsorships for this contextId" + ), + spendRequested: joi + .boolean() + .required() + .description( + "true if the client requested to spend sponsorship for this contextId" + ), + timestamp: joi + .number() + .required() + .description("the sponsorship timestamp"), + }), + }), + }, + schemas +); + +module.exports = { + schemas, + operations, +}; diff --git a/web_services/foxx/brightid5/tests/connections.js b/web_services/foxx/brightid5/tests/connections.js new file mode 100755 index 00000000..25e2dfb7 --- /dev/null +++ b/web_services/foxx/brightid5/tests/connections.js @@ -0,0 +1,405 @@ +"use strict"; + +const db = require("../db.js"); +const arango = require("@arangodb").db; +const usersColl = arango._collection("users"); +const connectionsColl = arango._collection("connections"); +const connectionsHistoryColl = arango._collection("connectionsHistory"); + +const chai = require("chai"); +const should = chai.should(); +const timestamp = Date.now(); + +describe("connections", function () { + before(function () { + usersColl.truncate(); + connectionsColl.truncate(); + connectionsHistoryColl.truncate(); + }); + after(function () { + usersColl.truncate(); + connectionsColl.truncate(); + connectionsHistoryColl.truncate(); + }); + it('should be able to use "addConnection" to set "just met" as confidence level', function () { + db.addConnection("a", "b", timestamp); + connectionsColl + .firstExample({ + _from: "users/a", + _to: "users/b", + }) + .level.should.equal("just met"); + connectionsColl + .firstExample({ + _from: "users/b", + _to: "users/a", + }) + .level.should.equal("just met"); + }); + it('should be able to use "connect" to upgrade confidence level to "already known"', function () { + db.connect({ id1: "b", id2: "a", level: "already known", timestamp }); + connectionsColl + .firstExample({ + _from: "users/b", + _to: "users/a", + }) + .level.should.equal("already known"); + }); + it('should be able to use "removeConnection" to report a connection that already knows the reporter', function () { + db.removeConnection("a", "b", "duplicate", timestamp); + const conn = connectionsColl.firstExample({ + _from: "users/a", + _to: "users/b", + }); + conn.level.should.equal("reported"); + conn.reportReason.should.equal("duplicate"); + }); + it('should be able to use "connect" to reset confidence level to "just met"', function () { + db.connect({ id1: "a", id2: "b", level: "just met", timestamp }); + const conn1 = connectionsColl.firstExample({ + _from: "users/a", + _to: "users/b", + }); + conn1.level.should.equal("just met"); + (conn1.reportReason === null).should.equal(true); + }); + it('should be able to use "setRecoveryConnections" to set "recovery" as confidence level', function () { + db.connect({ id1: "b", id2: "a", level: "already known", timestamp }); + db.setRecoveryConnections(["b"], "a", timestamp); + connectionsColl + .firstExample({ + _from: "users/a", + _to: "users/b", + }) + .level.should.equal("recovery"); + }); + it('should not reset "recovery" confidence level to "just met" when calling "addConnection"', function () { + db.addConnection("a", "b", timestamp); + connectionsColl + .firstExample({ + _from: "users/a", + _to: "users/b", + }) + .level.should.equal("recovery"); + connectionsColl + .firstExample({ + _from: "users/b", + _to: "users/a", + }) + .level.should.equal("already known"); + }); + it('should be able to use "connect" to set different confidence levels', function () { + db.connect({ + id1: "a", + id2: "b", + level: "reported", + reportReason: "duplicate", + timestamp, + }); + connectionsColl + .firstExample({ + _from: "users/a", + _to: "users/b", + }) + .level.should.equal("reported"); + db.connect({ id1: "a", id2: "b", level: "just met", timestamp }); + connectionsColl + .firstExample({ + _from: "users/a", + _to: "users/b", + }) + .level.should.equal("just met"); + db.connect({ id1: "a", id2: "b", level: "recovery", timestamp }); + connectionsColl + .firstExample({ + _from: "users/a", + _to: "users/b", + }) + .level.should.equal("recovery"); + db.connect({ id1: "a", id2: "c", level: "just met", timestamp }); + connectionsColl + .firstExample({ + _from: "users/a", + _to: "users/c", + }) + .level.should.equal("just met"); + }); + + it('should be able to use "setSigningKey" to reset "signingKey" with "recovery" connections', function () { + db.connect({ id1: "c", id2: "a", level: "already known", timestamp }); + db.connect({ id1: "a", id2: "c", level: "recovery", timestamp }); + db.setSigningKey("newSigningKey", "a", ["b", "c"], timestamp); + usersColl.document("a").signingKeys.should.deep.equal(["newSigningKey"]); + }); + + it('should be able to get "userConnections"', function () { + db.connect({ + id1: "c", + id2: "a", + level: "reported", + reportReason: "duplicate", + timestamp: 0, + }); + const conns = db.userConnections("b"); + conns.length.should.equal(1); + const a = conns[0]; + a.id.should.equal("a"); + a.level.should.equal("already known"); + }); + + it("should be able to report someone as replaced", function () { + db.connect({ + id1: "c", + id2: "a", + level: "reported", + reportReason: "replaced", + replacedWith: "b", + timestamp, + }); + const conn = connectionsColl.firstExample({ + _from: "users/c", + _to: "users/a", + }); + conn.level.should.equal("reported"); + conn.reportReason.should.equal("replaced"); + conn.replacedWith.should.equal("b"); + }); +}); + +describe("recovery connections", function () { + before(function () { + usersColl.truncate(); + connectionsColl.truncate(); + connectionsHistoryColl.truncate(); + }); + after(function () { + usersColl.truncate(); + connectionsColl.truncate(); + connectionsHistoryColl.truncate(); + }); + + it("users should be able add or remove recovery connections", function () { + db.connect({ id1: "b", id2: "a", level: "already known", timestamp: 1 }); + db.connect({ id1: "a", id2: "b", level: "recovery", timestamp: 1 }); + db.connect({ + id1: "c", + id2: "a", + level: "already known", + timestamp: Date.now() - 30 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "a", + id2: "c", + level: "recovery", + timestamp: Date.now() - 30 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "d", + id2: "a", + level: "already known", + timestamp: Date.now() - 29 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "a", + id2: "d", + level: "recovery", + timestamp: Date.now() - 29 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "e", + id2: "a", + level: "already known", + timestamp: Date.now() - 28 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "a", + id2: "e", + level: "recovery", + timestamp: Date.now() - 28 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "f", + id2: "a", + level: "already known", + timestamp: Date.now() - 22 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "a", + id2: "f", + level: "recovery", + timestamp: Date.now() - 22 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "a", + id2: "b", + level: "reported", + reportReason: "duplicate", + timestamp: Date.now() - 22 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "c", + id2: "b", + level: "already known", + timestamp: Date.now() - 21 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "b", + id2: "c", + level: "recovery", + timestamp: Date.now() - 21 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "c", + id2: "d", + level: "already known", + timestamp: Date.now() - 20 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "d", + id2: "c", + level: "recovery", + timestamp: Date.now(), + }); + db.connect({ + id1: "b", + id2: "c", + level: "already known", + timestamp: Date.now(), + }); + db.connect({ + id1: "a", + id2: "e", + level: "already known", + timestamp: Date.now() - 5 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "a", + id2: "e", + level: "recovery", + timestamp: Date.now() - 2 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "g", + id2: "a", + level: "already known", + timestamp: Date.now() - 10 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "a", + id2: "g", + level: "recovery", + timestamp: Date.now() - 5 * 24 * 60 * 60 * 1000, + }); + + const recoveryConnections = db.getRecoveryConnections("a"); + recoveryConnections.should.deep.equal(["c", "d", "e", "f"]); + }); + + it("should not be able to add a recovery connection without cooling period", function () { + db.connect({ + id1: "a", + id2: "b", + level: "recovery", + timestamp: Date.now(), + }); + let recoveryConnections = db.getRecoveryConnections("a"); + recoveryConnections.should.deep.equal(["c", "d", "e", "f"]); + + db.connect({ + id1: "a", + id2: "b", + level: "already known", + timestamp: Date.now(), + }); + recoveryConnections = db.getRecoveryConnections("a"); + recoveryConnections.should.deep.equal(["c", "d", "e", "f"]); + }); + + it("should not be able to inactive a recovery connection without cooling period", function () { + db.connect({ + id1: "a", + id2: "d", + level: "already known", + timestamp: Date.now(), + }); + let recoveryConnections = db.getRecoveryConnections("a"); + recoveryConnections.should.deep.equal(["c", "d", "e", "f"]); + + db.connect({ + id1: "a", + id2: "d", + level: "recovery", + timestamp: Date.now(), + }); + recoveryConnections = db.getRecoveryConnections("a"); + recoveryConnections.should.deep.equal(["c", "d", "e", "f"]); + }); + + it("remove recovery connection should take one week to take effect to protect against takeover", function () { + db.connect({ + id1: "a", + id2: "c", + level: "reported", + reportReason: "duplicate", + timestamp: Date.now(), + }); + + const recoveryConnections = db.getRecoveryConnections("a"); + recoveryConnections.should.deep.equal(["c", "d", "e", "f"]); + }); + + it("don't allow a recovery connection to be used for recovery if it is too new", function () { + db.connect({ + id1: "h", + id2: "a", + level: "already known", + timestamp: Date.now(), + }); + db.connect({ + id1: "a", + id2: "h", + level: "recovery", + timestamp: Date.now(), + }); + + const recoveryConnections = db.getRecoveryConnections("a"); + recoveryConnections.should.deep.equal(["c", "d", "e", "f"]); + }); + + it("ignore cooling period from recovery connections set in the first day", function () { + connectionsColl.truncate(); + connectionsHistoryColl.truncate(); + const firstConnTime = Date.now() - 4 * 24 * 60 * 60 * 1000; + db.connect({ id1: "b", id2: "a", level: "already known", timestamp: 1 }); + db.connect({ + id1: "a", + id2: "b", + level: "recovery", + timestamp: firstConnTime, + }); + db.connect({ id1: "c", id2: "a", level: "already known", timestamp: 1 }); + db.connect({ + id1: "a", + id2: "c", + level: "recovery", + timestamp: firstConnTime + 5 * 60 * 60 * 1000, + }); + db.connect({ id1: "d", id2: "a", level: "already known", timestamp: 1 }); + db.connect({ + id1: "a", + id2: "d", + level: "recovery", + timestamp: firstConnTime + 22 * 60 * 60 * 1000, + }); + db.connect({ id1: "e", id2: "a", level: "already known", timestamp: 1 }); + db.connect({ + id1: "a", + id2: "e", + level: "recovery", + timestamp: firstConnTime + 30 * 60 * 60 * 1000, + }); + + const recoveryConnections = db.getRecoveryConnections("a", "outbound"); + recoveryConnections.should.deep.equal(["b", "c", "d"]); + }); +}); diff --git a/web_services/foxx/brightid5/tests/encoding.js b/web_services/foxx/brightid5/tests/encoding.js new file mode 100644 index 00000000..7a63e203 --- /dev/null +++ b/web_services/foxx/brightid5/tests/encoding.js @@ -0,0 +1,81 @@ +"use strict"; + +const enc = require("../encoding.js"); +const nacl = require("tweetnacl"); +const chai = require("chai"); +const should = chai.should(); +const expect = chai.expect; + +const b64ToUint8Array = enc.b64ToUint8Array; +const strToUint8Array = enc.strToUint8Array; + +describe("encoding", function () { + describe("Uint8Array", function () { + it(`should be defined`, function () { + expect(typeof Uint8Array).to.not.equal("undefined"); + }); + }); + + const s = "xyzABCDE"; + + describe(`The b64 string "${s}"`, function () { + describe("decoded as a Uint8Array", function () { + let e = ""; + try { + const array = Object.values(b64ToUint8Array(s)); + const expected_array = [199, 44, 192, 4, 32, 196]; + it(`should equal ${JSON.stringify(expected_array)}`, function () { + array.should.have.members(expected_array); + }); + it("should be a Uint8Array", function () { + expect(array instanceof Array); + }); + } catch (err) { + e = err; + } + it("should succeed", function () { + e.should.not.be.an("error"); + }); + }); + }); + + let safeB64 = []; + let regularB64 = []; + + safeB64[0] = "test-"; + regularB64[0] = "test+==="; + safeB64[1] = "_test-"; + regularB64[1] = "/test+=="; + safeB64[2] = "__test-"; + regularB64[2] = "//test+="; + safeB64[3] = "___test-"; + regularB64[3] = "///test+"; + + for (let i = 0; i <= 3; ++i) { + describe(`The url-safe b64 string "${safeB64[i]}"`, function () { + it(`should convert to the b64 string "${regularB64[i]}"`, function () { + enc.urlSafeB64ToB64(safeB64[i]).should.equal(regularB64[i]); + }); + }); + describe(`The b64 string "${regularB64[i]}"`, function () { + it(`should convert to the url-safe b64 string "${safeB64[i]}"`, function () { + enc.b64ToUrlSafeB64(regularB64[i]).should.equal(safeB64[i]); + }); + }); + } + + describe(`A b64 encoded publicKey and sig`, function () { + it(`should be usable by tweetnacl`, function () { + const publicKey = "zcsKbTYYKYc31hj/FCAmJlsizz2gOJRk+oOQYgQIpUg="; + const message = strToUint8Array("message"); + const sig = + "W9tcoxwdXr4er5FxH7LONOYKYSm1+DstAuhMhhuJXpzdlir5vbuIdfTAQDEABoyBqGtwhyKsKkMmsz8aD/wACQ=="; + (() => + nacl.sign.detached.verify( + message, + b64ToUint8Array(sig), + b64ToUint8Array(publicKey) + )).should.not.throw(); + }); + }); +}); diff --git a/web_services/foxx/brightid5/tests/errors.js b/web_services/foxx/brightid5/tests/errors.js new file mode 100755 index 00000000..e849fa5d --- /dev/null +++ b/web_services/foxx/brightid5/tests/errors.js @@ -0,0 +1,188 @@ +"use strict"; + +const db = require("../db.js"); +const _ = require("lodash"); +const { getMessage } = require("../operations"); +const errors = require("../errors"); +const arango = require("@arangodb").db; +const request = require("@arangodb/request"); +const nacl = require("tweetnacl"); +nacl.setPRNG(function (x, n) { + for (let i = 0; i < n; i++) { + x[i] = Math.floor(Math.random() * 256); + } +}); +const { + b64ToUrlSafeB64, + uInt8ArrayToB64, + strToUint8Array, + hash, +} = require("../encoding"); +const chai = require("chai"); + +const { baseUrl } = module.context; +const applyBaseUrl = baseUrl.replace("/brightid5", "/apply5"); + +const usersColl = arango._collection("users"); +const operationsColl = arango._collection("operations"); + +const should = chai.should(); + +const u1 = nacl.sign.keyPair(); +const u2 = nacl.sign.keyPair(); +const u3 = nacl.sign.keyPair(); + +let { publicKey: sponsorPublicKey, secretKey: sponsorPrivateKey } = + nacl.sign.keyPair(); +let { secretKey: linkAESKey } = nacl.sign.keyPair(); + +const contextId = "0x636D49c1D76ff8E04767C68fe75eC9900719464b".toLowerCase(); +const contextName = "ethereum"; + +function apply(op) { + let resp = request.post(`${baseUrl}/operations`, { + body: op, + json: true, + }); + if (resp.status != 200) { + return resp; + } + resp.status.should.equal(200); + let h = hash(getMessage(op)); + resp.json.data.hash.should.equal(h); + op = operationsColl.document(h); + op = _.omit(op, ["_rev", "_id", "_key", "hash", "state"]); + op.blockTime = op.timestamp; + resp = request.put(`${applyBaseUrl}/operations/${h}`, { + body: op, + json: true, + }); + resp.json.success.should.equal(true); + if ((resp.state = "failed")) { + return resp; + } +} + +describe("errors", function () { + before(function () { + usersColl.truncate(); + operationsColl.truncate(); + [u1, u2, u3].map((u) => { + u.signingKey = uInt8ArrayToB64(Object.values(u.publicKey)); + u.id = b64ToUrlSafeB64(u.signingKey); + db.createUser(u.id, Date.now()); + }); + }); + + after(function () { + usersColl.truncate(); + operationsColl.truncate(); + }); + + it("should throw INVALID_SIGNATURE when operation signed by wrong user", function () { + const timestamp = Date.now(); + let op = { + v: 5, + name: "Add Connection", + id1: u1.id, + id2: u2.id, + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + op.sig2 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u3.secretKey)) + ); + const resp = apply(op); + resp.json.code.should.equal(401); + resp.json.errorNum.should.equal(errors.INVALID_SIGNATURE); + }); + + it("should throw INVALID_COFOUNDERS when try to create group by not connected users", function () { + const timestamp = Date.now(); + const type = "general"; + const url = "http://url.com/dummy"; + const groupId = hash("randomstr"); + + const op = { + v: 5, + name: "Add Group", + group: groupId, + id1: u1.id, + id2: u2.id, + inviteData2: "data", + id3: u3.id, + inviteData3: "data", + url, + type, + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + const resp = apply(op); + resp.json.result.errorNum.should.equal(errors.INVALID_COFOUNDERS); + }); + + it("should throw OperationNotFoundError when the operation does not exist", function () { + const hash = "testHash"; + const resp = request.get(`${baseUrl}/operations/${hash}`); + resp.json.code.should.equal(404); + resp.json.errorNum.should.equal(errors.OPERATION_NOT_FOUND); + resp.json.errorMessage.should.equal(`The operation ${hash} is not found.`); + }); + + it("should throw UserNotFoundError when the user does not exist", function () { + const id = "testId"; + const resp = request.get(`${baseUrl}/users/${id}`); + resp.json.code.should.equal(404); + resp.json.errorNum.should.equal(errors.USER_NOT_FOUND); + resp.json.errorMessage.should.equal(`The user ${id} is not found.`); + }); + + it("handle joi errors (invalid operation)", function () { + const timestamp = Date.now(); + let op = { + v: 5, + name: "Add new Connection", + id1: u1.id, + id2: u2.id, + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + op.sig2 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + const resp = apply(op); + resp.json.errorNum.should.equal(400); + resp.json.code.should.equal(400); + resp.json.errorMessage.should.equal("invalid operation name"); + }); + + it("handle joi errors (missed parameter of an operation)", function () { + const timestamp = Date.now(); + let op = { + v: 5, + name: "Add Connection", + id2: u2.id, + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + op.sig2 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + const resp = apply(op); + resp.json.errorNum.should.equal(400); + resp.json.code.should.equal(400); + resp.json.errorMessage.should.equal('"id1" is required, '); + }); +}); diff --git a/web_services/foxx/brightid5/tests/graph.js b/web_services/foxx/brightid5/tests/graph.js new file mode 100755 index 00000000..50eae53f --- /dev/null +++ b/web_services/foxx/brightid5/tests/graph.js @@ -0,0 +1,36 @@ +"use strict"; + +const db = require("../db.js"); +const arango = require("@arangodb").db; +const connectionsColl = arango._collection("connections"); +const groupsColl = arango._collection("groups"); +const usersInGroupsColl = arango._collection("usersInGroups"); +const usersColl = arango._collection("users"); + +const chai = require("chai"); +const should = chai.should(); + +describe("db graph", function () { + before(function () { + connectionsColl.truncate(); + usersColl.truncate(); + db.createUser("a"); + db.createUser("b"); + }); + after(function () { + connectionsColl.truncate(); + usersColl.truncate(); + }); + it("should be able to retrieve a score for a user", function () { + db.userScore("a").should.equal(0); + }); + it("should be able to create a connection", function () { + db.connect({ id1: "a", id2: "b", level: "already known" }); + }); + it("should be able to remove a connection", function () { + db.removeConnection("b", "a", "duplicate", Date.now()); + }); + it("should be able to re-add a connection", function () { + db.addConnection("b", "a", Date.now()); + }); +}); diff --git a/web_services/foxx/brightid5/tests/groups.js b/web_services/foxx/brightid5/tests/groups.js new file mode 100755 index 00000000..1a6b68fd --- /dev/null +++ b/web_services/foxx/brightid5/tests/groups.js @@ -0,0 +1,270 @@ +"use strict"; + +const db = require("../db.js"); +const errors = require("../errors.js"); +const arango = require("@arangodb").db; +const { hash } = require("../encoding"); + +const connectionsColl = arango._collection("connections"); +const groupsColl = arango._collection("groups"); +const usersInGroupsColl = arango._collection("usersInGroups"); +const usersColl = arango._collection("users"); +const invitationsColl = arango._collection("invitations"); + +const chai = require("chai"); +const should = chai.should(); +const expect = chai.expect; +const url = "http://url.com/dummy"; + +describe("groups", function () { + before(function () { + usersColl.truncate(); + connectionsColl.truncate(); + groupsColl.truncate(); + usersInGroupsColl.truncate(); + invitationsColl.truncate(); + db.createUser("a"); + db.createUser("b"); + db.createUser("c"); + db.createUser("d"); + db.createUser("e"); + db.createUser("f"); + db.addConnection("b", "c", 0); + db.addConnection("b", "d", 0); + db.addConnection("a", "b", 0); + db.addConnection("a", "c", 0); + db.addConnection("a", "d", 0); + }); + after(function () { + usersColl.truncate(); + connectionsColl.truncate(); + groupsColl.truncate(); + usersInGroupsColl.truncate(); + invitationsColl.truncate(); + }); + it("should be able to create a group", function () { + db.createGroup( + "g1", + "b", + "c", + "data", + "d", + "data", + url, + "general", + Date.now() + ); + groupsColl.count().should.equal(1); + const group = groupsColl.any(); + group._key.should.equal("g1"); + group.isNew.should.equal(true); + }); + it("should be able to delete a group", function () { + db.deleteGroup("g1", "b", Date.now()); + groupsColl.count().should.equal(0); + }); + it("should be able to create the group again", function () { + db.createGroup( + "g2", + "b", + "c", + "data", + "d", + "data", + url, + "general", + Date.now() + ); + groupsColl.count().should.equal(1); + groupsColl.any()._key.should.equal("g2"); + }); + it("the two co-founders should be able to join the group", function () { + db.addMembership("g2", "c", Date.now()); + db.addMembership("g2", "d", Date.now()); + }); + it("the group should be upgraded from a new group to a normal group", function () { + groupsColl.count().should.equal(1); + groupsColl.any().isNew.should.equal(false); + }); + + describe("a user connected to all three members of a group", function () { + it("should have three connections", function () { + db.userConnections("a").length.should.equal(3); + }); + it("should not be able to join the group without invitation", function () { + (() => { + db.addMembership("g2", "a", Date.now()); + }).should.throw("not invited to join this group"); + }); + it("should be able to join the group after invitation", function () { + db.invite("b", "a", "g2", "data", Date.now()); + db.addMembership("g2", "a", Date.now()); + usersInGroupsColl.count().should.equal(4); + }); + it("should be able to leave the group", function () { + db.deleteMembership("g2", "a", Date.now()); + usersInGroupsColl.count().should.equal(3); + }); + }); + + describe("inviting", function () { + before(function () { + db.createUser("g"); + db.addConnection("a", "b", 0); + db.addConnection("a", "c", 0); + db.addConnection("a", "d", 0); + db.addConnection("b", "d", 0); + db.addConnection("c", "d", 0); + db.createGroup( + "g3", + "a", + "b", + "data", + "c", + "data", + url, + "general", + Date.now() + ); + db.addMembership("g3", "b", Date.now()); + db.addMembership("g3", "c", Date.now()); + }); + it("no one should be able to join an invite only group without invitation", function () { + (() => { + db.addMembership("g3", "d", Date.now()); + }).should.throw("not invited to join this group"); + }); + it("admins should be able to invite any users to the group", function () { + db.invite("b", "d", "g3", "data", Date.now()); + db.userInvitedGroups("d") + .map((group) => group.id) + .should.deep.equal(["g3"]); + }); + it("invited user should be able to join the group", function () { + db.addMembership("g3", "d", Date.now()); + db.groupMembers("g3").should.include("d"); + db.userInvitedGroups("d").length.should.equal(0); + }); + it("non-admins should not be able to invite others to the group", function () { + (() => { + db.invite("d", "e", "g3", "data", Date.now()); + }).should.throw(errors.NotAdminError); + }); + }); + + describe("dismissing", function () { + before(function () { + db.addConnection("a", "d", 0); + db.addConnection("b", "d", 0); + db.addConnection("c", "d", 0); + db.addConnection("a", "e", 0); + db.addConnection("b", "e", 0); + db.addConnection("c", "e", 0); + db.invite("b", "d", "g3", "data", Date.now()); + db.invite("b", "e", "g3", "data", Date.now()); + db.addMembership("g3", "d", Date.now()); + db.addMembership("g3", "e", Date.now()); + }); + it("non-admins should not be able to dismiss others from the group", function () { + (() => { + db.dismiss("d", "e", "g3", Date.now()); + }).should.throw(errors.NotAdminError); + }); + it("admins should be able to dismiss others from the group", function () { + db.dismiss("b", "d", "g3", Date.now()); + db.groupMembers("g3").should.not.include("d"); + }); + }); + + describe("adding new admins", function () { + before(function () { + db.invite("b", "d", "g3", "data", Date.now()); + db.addMembership("g3", "d", Date.now()); + }); + it("non-admins should not be able to add new admins", function () { + (() => { + db.addAdmin("e", "d", "g3", Date.now()); + }).should.throw(errors.NotAdminError); + }); + it("admins should be able to add new admins", function () { + db.addAdmin("b", "d", "g3", Date.now()); + groupsColl.document("g3").admins.should.include("d"); + }); + it("new admins should be able to dismiss others from the group", function () { + db.dismiss("d", "e", "g3", Date.now()); + db.groupMembers("g3").should.not.include("e"); + }); + it("admins should be removed from admins list when they leave the group", function () { + groupsColl.document("g3").admins.should.include("d"); + db.deleteMembership("g3", "d", Date.now()); + groupsColl.document("g3").admins.should.not.include("d"); + }); + }); + + describe("primary groups", function () { + before(function () { + groupsColl.truncate(); + groupsColl.truncate(); + usersInGroupsColl.truncate(); + db.addConnection("a", "d", 0); + db.addConnection("a", "e", 0); + db.addConnection("e", "c", 0); + db.addConnection("e", "b", 0); + db.createGroup( + "g4", + "a", + "b", + "data", + "c", + "data", + url, + "primary", + Date.now() + ); + db.addMembership("g4", "b", Date.now()); + db.addMembership("g4", "c", Date.now()); + }); + it("users that have primary groups should not be able to create new primary groups", function () { + (() => { + db.createGroup( + "g5", + "a", + "d", + "data", + "e", + "data", + url, + "primary", + Date.now() + ); + }).should.throw(errors.AlreadyHasPrimaryGroupError); + }); + it("users with no primary group should be able to join a primary group", function () { + db.invite("a", "d", "g4", "data", Date.now()); + db.addMembership("g4", "d", Date.now()); + db.userGroups("d") + .map((group) => group.id) + .should.deep.equal(["g4"]); + }); + it("users that have primary groups should not be able to invited to other primary groups", function () { + db.addConnection("e", "f", Date.now()); + db.addConnection("e", "g", Date.now()); + db.createGroup( + "g6", + "e", + "f", + "data", + "g", + "data", + url, + "primary", + Date.now() + ); + db.addMembership("g6", "f", Date.now()); + db.addMembership("g6", "g", Date.now()); + (() => { + db.invite("a", "e", "g4", "data", Date.now()); + }).should.throw(errors.AlreadyHasPrimaryGroupError); + }); + }); +}); diff --git a/web_services/foxx/brightid5/tests/links.js b/web_services/foxx/brightid5/tests/links.js new file mode 100755 index 00000000..e2a417f4 --- /dev/null +++ b/web_services/foxx/brightid5/tests/links.js @@ -0,0 +1,150 @@ +"use strict"; + +const db = require("../db.js"); +const errors = require("../errors.js"); +const arango = require("@arangodb").db; +const query = require("@arangodb").query; +const chai = require("chai"); + +let contextIdsColl; +const usersColl = arango._collection("users"); +const contextsColl = arango._collection("contexts"); +const appsColl = arango._collection("apps"); +const sponsorshipsColl = arango._collection("sponsorships"); +const variablesColl = arango._collection("variables"); +const verificationsColl = arango._collection("verifications"); +const should = chai.should(); + +describe("links & sponsorships", function () { + before(function () { + contextIdsColl = arango._create("testIds"); + usersColl.truncate(); + contextsColl.truncate(); + appsColl.truncate(); + sponsorshipsColl.truncate(); + query` + INSERT { + _key: "testContext", + collection: "testIds" + } IN ${contextsColl} + `; + query` + INSERT { + _key: "testApp", + totalSponsorships: 1, + verification: "BrightID", + context: "testContext" + } IN ${appsColl} + `; + query` + INSERT { + _key: "2" + } IN ${usersColl} + `; + query` + INSERT { + _key: "3" + } IN ${usersColl} + `; + query` + INSERT { + _key: "4" + } IN ${usersColl} + `; + const hashes = JSON.parse( + variablesColl.document("VERIFICATIONS_HASHES").hashes + ); + const block = Math.max(...Object.keys(hashes)); + query` + INSERT { + name: "BrightID", + user: "2", + block: ${block} + } IN ${verificationsColl} + `; + query` + INSERT { + name: "BrightID", + user: "3", + block: ${block} + } IN ${verificationsColl} + `; + query` + INSERT { + name: "BrightID", + user: "4", + block: ${block} + } IN ${verificationsColl} + `; + }); + after(function () { + arango._drop(contextIdsColl); + usersColl.truncate(); + contextsColl.truncate(); + appsColl.truncate(); + sponsorshipsColl.truncate(); + }); + context("linkContextId()", function () { + it("should throw DuplicateContextIdError for used contextId", function () { + db.linkContextId("2", "testContext", "used", 5); + (() => { + db.linkContextId("3", "testContext", "used", 10); + }).should.throw(errors.DuplicateContextIdError); + }); + it("should allow same user to relink used contextIds", function () { + db.linkContextId("2", "testContext", "second", 11); + db.linkContextId("2", "testContext", "used", 10); + }); + it("should return add link if contextId and timestamp are OK", function () { + db.linkContextId("3", "testContext", "testContextId", 10); + db.getUserByContextId(contextIdsColl, "testContextId").should.equal("3"); + }); + it("should not be able to link more than 3 contextIds in a single day", function () { + db.linkContextId("3", "testContext", "testContextId2", 15); + db.linkContextId("3", "testContext", "testContextId3", 20); + (() => { + db.linkContextId("3", "testContext", "testContextId4", 25); + }).should.throw(errors.TooManyLinkRequestError); + }); + it("should be able to link new contextId after 24 hours", function () { + db.linkContextId( + "3", + "testContext", + "testContextId4", + 24 * 3600 * 1000 + 25 + ); + }); + it("should throw UserNotFoundError if user doesn't exist", function () { + (() => { + db.linkContextId("6", "testContext", "newcontextid", 10); + }).should.throw(errors.UserNotFoundError); + }); + }); + context("sponsor()", function () { + it("should be able to sponsor a user if app has unused sponsorships and user is not sponsored before", function () { + db.sponsor({ + name: "Sponsor", + contextId: "2", + app: "testApp", + timestamp: 0, + }); + db.sponsor({ + name: "Spend Sponsorship", + contextId: "2", + app: "testApp", + timestamp: 0, + }); + appsColl.document("testApp").usedSponsorships.should.equal(1); + }); + it("should throw UnusedSponsorshipsError if app has no unused sponsorship", function () { + (() => { + db.sponsor({ + name: "Sponsor", + contextId: "3", + app: "testApp", + timestamp: 0, + }); + }).should.throw(errors.UnusedSponsorshipsError); + }); + }); +}); diff --git a/web_services/foxx/brightid5/tests/operations.js b/web_services/foxx/brightid5/tests/operations.js new file mode 100755 index 00000000..d4515eb2 --- /dev/null +++ b/web_services/foxx/brightid5/tests/operations.js @@ -0,0 +1,1028 @@ +"use strict"; + +const secp256k1 = require("secp256k1"); +const createKeccakHash = require("keccak"); +const db = require("../db.js"); +const errors = require("../errors.js"); +const _ = require("lodash"); +const { getMessage } = require("../operations"); +const { db: arango, query } = require("@arangodb"); +const request = require("@arangodb/request"); +const nacl = require("tweetnacl"); +nacl.setPRNG(function (x, n) { + for (let i = 0; i < n; i++) { + x[i] = Math.floor(Math.random() * 256); + } +}); +const { + b64ToUrlSafeB64, + uInt8ArrayToB64, + strToUint8Array, + b64ToUint8Array, + hash, + pad32, + addressToBytes32, +} = require("../encoding"); + +const { baseUrl } = module.context; +const applyBaseUrl = baseUrl.replace("/brightid5", "/apply5"); + +let hashes; +let contextIdsColl, contextIdsColl2; +const connectionsColl = arango._collection("connections"); +const groupsColl = arango._collection("groups"); +const usersInGroupsColl = arango._collection("usersInGroups"); +const usersColl = arango._collection("users"); +const operationsColl = arango._collection("operations"); +const contextsColl = arango._collection("contexts"); +const appsColl = arango._collection("apps"); +const sponsorshipsColl = arango._collection("sponsorships"); +const operationsHashesColl = arango._collection("operationsHashes"); +const invitationsColl = arango._collection("invitations"); +const verificationsColl = arango._collection("verifications"); +const operationCountersColl = arango._collection("operationCounters"); +const variablesColl = arango._collection("variables"); + +const chai = require("chai"); +const should = chai.should(); + +const u1 = nacl.sign.keyPair(); +const u2 = nacl.sign.keyPair(); +const u3 = nacl.sign.keyPair(); +const u4 = nacl.sign.keyPair(); +const u5 = nacl.sign.keyPair(); +const u6 = nacl.sign.keyPair(); +const u7 = nacl.sign.keyPair(); + +let { publicKey: sponsorPublicKey, secretKey: sponsorPrivateKey } = + nacl.sign.keyPair(); +let { secretKey: linkAESKey } = nacl.sign.keyPair(); + +const contextId = "0x636D49c1D76ff8E04767C68fe75eC9900719464b".toLowerCase(); +const contextId2 = "0x636D49c1D76ff8E04767C68fe75eC9900719464a".toLowerCase(); +const contextName = "ethereum"; +const app = "ethereum"; + +const soulboundContextName = "soulboundToken"; +const soulboundApp = "soulboundToken"; + +const soulboundMessage = "it's a test message."; + +function apply(op) { + let resp = request.post(`${baseUrl}/operations`, { + body: op, + json: true, + }); + resp.status.should.equal(200); + let h = hash(getMessage(op)); + resp.json.data.hash.should.equal(h); + op = operationsColl.document(h); + op = _.omit(op, ["_rev", "_id", "_key", "hash", "state"]); + op.blockTime = op.timestamp; + resp = request.put(`${applyBaseUrl}/operations/${h}`, { + body: op, + json: true, + }); + resp.json.success.should.equal(true); + return resp; +} + +describe("operations", function () { + before(function () { + contextIdsColl = arango._collection(contextName); + if (contextIdsColl) { + contextIdsColl.truncate(); + } else { + contextIdsColl = arango._create(contextName); + } + contextIdsColl2 = arango._collection(soulboundContextName); + if (contextIdsColl2) { + contextIdsColl2.truncate(); + } else { + contextIdsColl2 = arango._create(soulboundContextName); + } + operationsHashesColl.truncate(); + usersColl.truncate(); + connectionsColl.truncate(); + groupsColl.truncate(); + usersInGroupsColl.truncate(); + operationsColl.truncate(); + contextsColl.truncate(); + appsColl.truncate(); + sponsorshipsColl.truncate(); + invitationsColl.truncate(); + verificationsColl.truncate(); + [u1, u2, u3, u4, u5, u6, u7].forEach((u) => { + u.signingKey = uInt8ArrayToB64(Object.values(u.publicKey)); + u.id = b64ToUrlSafeB64(u.signingKey); + db.createUser(u.id, Date.now()); + }); + contextsColl.insert({ + _key: contextName, + collection: contextName, + linkAESKey: uInt8ArrayToB64(Object.values(linkAESKey)), + idsAsHex: true, + }); + appsColl.insert({ + _key: app, + context: contextName, + totalSponsorships: 5, + verification: "BrightID", + idsAsHex: true, + sponsorPublicKey: uInt8ArrayToB64(Object.values(sponsorPublicKey)), + }); + contextsColl.insert({ + _key: soulboundContextName, + collection: soulboundContextName, + soulboundMessage, + linkAESKey: uInt8ArrayToB64(Object.values(linkAESKey)), + soulbound: true, + }); + appsColl.insert({ + _key: soulboundApp, + context: soulboundContextName, + totalSponsorships: 1, + verification: "BrightID", + soulbound: true, + sponsorPublicKey: uInt8ArrayToB64(Object.values(sponsorPublicKey)), + }); + const hashes = JSON.parse( + variablesColl.document("VERIFICATIONS_HASHES").hashes + ); + const block = Math.max(...Object.keys(hashes)); + verificationsColl.insert({ + name: "BrightID", + user: u1.id, + block, + }); + verificationsColl.insert({ + name: "BrightID", + user: u3.id, + block, + }); + verificationsColl.insert({ + name: "SeedConnected", + user: u1.id, + rank: 3, + block, + }); + verificationsColl.insert({ + name: "BrightID", + user: u7.id, + block, + }); + operationCountersColl.truncate(); + }); + + after(function () { + operationsHashesColl.truncate(); + contextsColl.truncate(); + appsColl.truncate(); + arango._drop(contextIdsColl); + arango._drop(contextIdsColl2); + usersColl.truncate(); + connectionsColl.truncate(); + groupsColl.truncate(); + usersInGroupsColl.truncate(); + operationsColl.truncate(); + sponsorshipsColl.truncate(); + invitationsColl.truncate(); + verificationsColl.truncate(); + operationCountersColl.truncate(); + }); + + it('should be able to "Add Connection"', function () { + const connect = (u1, u2) => { + const timestamp = Date.now(); + let op = { + v: 5, + name: "Add Connection", + id1: u1.id, + id2: u2.id, + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), u1.secretKey) + ) + ); + op.sig2 = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), u2.secretKey) + ) + ); + apply(op); + }; + connect(u1, u2); + connect(u1, u3); + connect(u2, u3); + connect(u2, u4); + connect(u3, u4); + db.userConnections(u1.id).length.should.equal(2); + db.userConnections(u2.id).length.should.equal(3); + db.userConnections(u3.id).length.should.equal(3); + }); + + it('should be able to "Remove Connection"', function () { + db.connect({ id1: u3.id, id2: u2.id, level: "already known" }); + const timestamp = Date.now(); + const reason = "duplicate"; + + let op = { + v: 5, + name: "Remove Connection", + id1: u2.id, + id2: u3.id, + reason, + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + apply(op); + connectionsColl + .firstExample({ + _from: "users/" + u2.id, + _to: "users/" + u3.id, + }) + .reportReason.should.equal(reason); + connectionsColl + .firstExample({ + _from: "users/" + u3.id, + _to: "users/" + u2.id, + }) + .level.should.equal("already known"); + }); + + it('should be able to "Add Group"', function () { + const timestamp = Date.now(); + const type = "general"; + const url = "http://url.com/dummy"; + const groupId = hash("randomstr"); + + const op = { + v: 5, + name: "Add Group", + group: groupId, + id1: u1.id, + id2: u2.id, + inviteData2: "data", + id3: u3.id, + inviteData3: "data", + url, + type, + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + apply(op); + + const members = db.groupMembers(groupId); + members.should.include(u1.id); + members.should.not.include(u2.id); + members.should.not.include(u3.id); + }); + + it('should be able to "Add Membership"', function () { + const groupId = db.userGroups(u1.id)[0].id; + [u2, u3].map((u) => { + const timestamp = Date.now(); + const op = { + v: 5, + name: "Add Membership", + id: u.id, + group: groupId, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u.secretKey)) + ); + apply(op); + }); + const members = db.groupMembers(groupId); + members.should.include(u2.id); + members.should.include(u3.id); + }); + + it('should be able to "Remove Membership"', function () { + const timestamp = Date.now(); + const groupId = db.userGroups(u1.id)[0].id; + const op = { + v: 5, + name: "Remove Membership", + id: u1.id, + group: groupId, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + apply(op); + + const members = db.groupMembers(groupId, false); + members.should.not.include(u1.id); + members.should.include(u2.id); + members.should.include(u3.id); + }); + + it('admins should be able to "Invite" someone to the group', function () { + const timestamp = Date.now(); + const groupId = db.userGroups(u2.id)[0].id; + const data = "some data"; + const op = { + v: 5, + name: "Invite", + inviter: u2.id, + invitee: u4.id, + group: groupId, + data, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + apply(op); + invitationsColl + .byExample({ + _from: "users/" + u4.id, + _to: "groups/" + groupId, + }) + .count() + .should.equal(1); + }); + + it('admins should be able to "Dismiss" someone from the group', function () { + const timestamp = Date.now(); + const groupId = db.userGroups(u2.id)[0].id; + db.addMembership(groupId, u4.id, Date.now()); + db.groupMembers(groupId).should.include(u4.id); + const op = { + v: 5, + name: "Dismiss", + dismisser: u2.id, + dismissee: u4.id, + group: groupId, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + apply(op); + db.groupMembers(groupId).should.not.include(u4.id); + }); + + it('admins should be able to "Add Admin" to the group', function () { + const timestamp = Date.now(); + const groupId = db.userGroups(u2.id)[0].id; + db.invite(u2.id, u4.id, groupId, "data", Date.now()); + db.addMembership(groupId, u4.id, Date.now()); + db.groupMembers(groupId).should.include(u4.id); + const op = { + v: 5, + name: "Add Admin", + id: u2.id, + admin: u4.id, + group: groupId, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + apply(op); + groupsColl.document(groupId).admins.should.include(u4.id); + }); + + it('admins should be able "Update Group" to edit name and photo for groups', function () { + const newUrl = "http://url.com/newDummyUrl"; + const timestamp = Date.now(); + const groupId = db.userGroups(u2.id)[0].id; + const op = { + v: 5, + name: "Update Group", + id: u2.id, + url: newUrl, + group: groupId, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + apply(op); + groupsColl.document(groupId).url.should.equal(newUrl); + }); + + it("should not be able to make a recovery connection when the other side connection is not equal to 'recovery' or 'already known'", function () { + const timestamp = Date.now(); + + let op = { + v: 5, + name: "Connect", + id1: u1.id, + id2: u2.id, + level: "recovery", + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + const resp = apply(op); + resp.json.result.errorNum.should.equal( + errors.INELIGIBLE_RECOVERY_CONNECTION + ); + connectionsColl + .firstExample({ + _from: "users/" + u1.id, + _to: "users/" + u2.id, + }) + .level.should.equal("just met"); + }); + + it('should be able to "Set Trusted Connections"', function () { + db.connect({ id1: u2.id, id2: u1.id, level: "already known" }); + db.connect({ id1: u3.id, id2: u1.id, level: "already known" }); + const timestamp = Date.now(); + const op = { + v: 5, + name: "Set Trusted Connections", + id: u1.id, + trusted: [u2.id, u3.id], + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + apply(op); + + connectionsColl + .firstExample({ + _from: "users/" + u1.id, + _to: "users/" + u2.id, + }) + .level.should.equal("recovery"); + connectionsColl + .firstExample({ + _from: "users/" + u1.id, + _to: "users/" + u3.id, + }) + .level.should.equal("recovery"); + }); + + it('should be able to "Set Signing Key"', function () { + const timestamp = Date.now(); + const op = { + v: 5, + name: "Set Signing Key", + id: u1.id, + id1: u2.id, + id2: u3.id, + signingKey: u4.signingKey, + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + op.sig2 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u3.secretKey)) + ); + apply(op); + db.loadUser(u1.id).signingKeys.should.deep.equal([u4.signingKey]); + u1.secretKey = u4.secretKey; + }); + + it('should be able to "Link ContextId"', function () { + const timestamp = Date.now(); + const op = { + v: 5, + name: "Link ContextId", + context: contextName, + timestamp, + id: u1.id, + contextId, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u4.secretKey)) + ); + apply(op); + db.getContextIdsByUser(contextIdsColl, u1.id)[0].should.equal(contextId); + }); + + it('unverified users should not be able to "Link ContextId"', function () { + const timestamp = Date.now(); + const op = { + v: 5, + name: "Link ContextId", + context: contextName, + timestamp, + id: u2.id, + contextId: contextId2, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + const resp = apply(op); + resp.json.state.should.equal('failed'); + resp.json.result.errorNum.should.equal(errors.NOT_VERIFIED); + }); + + it("should be able to linking with Ethereum-signed messages for the soulbound apps", function () { + const exampleSig = { + address: "0xcc15be495d8c8996eefdfc78b2b23ba2fa92d67b", + msg: "0x6974277320612074657374206d6573736167652e", + sig: "e79413f9425cef5771d73321554f96483ef491e7579e85e518fcf84e0948646c393f9afed2de78fa22bf32b0fa4c66cec4d441b90c380f4f67bf8395a551e1111c", + }; + + const timestamp = Date.now(); + const op = { + v: 5, + name: "Link ContextId", + context: soulboundContextName, + timestamp, + id: u3.id, + contextId: exampleSig.sig, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u3.secretKey)) + ); + const res = apply(op); + db.getContextIdsByUser(contextIdsColl2, u3.id)[0].should.equal( + exampleSig.address + ); + let resp = request.get( + `${baseUrl}/verifications/${soulboundApp}/${exampleSig.address}`, + { + qs: { + signed: "nacl", + }, + json: true, + } + ); + resp.status.should.equal(200); + resp.json.data.unique.should.equal(true); + }); + + it('should be able to "Connect"', function () { + const timestamp = Date.now(); + + let op = { + v: 5, + name: "Connect", + id1: u1.id, + id2: u2.id, + level: "just met", + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + apply(op); + connectionsColl + .firstExample({ + _from: "users/" + u1.id, + _to: "users/" + u2.id, + }) + .level.should.equal("just met"); + }); + + it('should be able to report using "Connect" by providing requestProof', function () { + const timestamp = Date.now(); + + const requestProofMessage = u1.id + "|" + timestamp; + const requestProof = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(requestProofMessage), u1.secretKey) + ) + ); + let op = { + v: 5, + name: "Connect", + id1: u2.id, + id2: u1.id, + level: "reported", + reportReason: "spammer", + requestProof, + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + apply(op); + const conn = connectionsColl.firstExample({ + _from: "users/" + u2.id, + _to: "users/" + u1.id, + }); + conn.level.should.equal("reported"); + conn.requestProof.should.equal(requestProof); + }); + + it('should be able to "Add Signing Key"', function () { + const addSigningKey = (u, signingKey) => { + const timestamp = Date.now(); + const op = { + v: 5, + id: u.id, + name: "Add Signing Key", + signingKey, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u.secretKey)) + ); + apply(op); + }; + addSigningKey(u2, u5.signingKey); + addSigningKey(u2, u6.signingKey); + db.loadUser(u2.id).signingKeys.should.deep.equal([ + u2.signingKey, + u5.signingKey, + u6.signingKey, + ]); + }); + + it('should be able to "Remove Signing Key"', function () { + const timestamp = Date.now(); + const op = { + v: 5, + id: u2.id, + name: "Remove Signing Key", + signingKey: u5.signingKey, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + apply(op); + db.loadUser(u2.id).signingKeys.should.deep.equal([ + u2.signingKey, + u6.signingKey, + ]); + }); + + it("should be able to sign an operation using new Signing Key", function () { + const timestamp = Date.now(); + let op = { + v: 5, + name: "Connect", + id1: u2.id, + id2: u3.id, + level: "recovery", + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u6.secretKey)) + ); + apply(op); + db.userConnections(u2.id) + .filter((u) => u.id == u3.id)[0] + .level.should.equal("recovery"); + }); + + it('should be able to "Remove All Signing Keys"', function () { + const timestamp = Date.now(); + const op = { + v: 5, + id: u2.id, + name: "Remove All Signing Keys", + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u6.secretKey)) + ); + apply(op); + db.loadUser(u2.id).signingKeys.should.deep.equal([u6.signingKey]); + }); + + describe("Sponsoring and getting verification", function () { + it('apps should be able to "Sponsor" first then clients "Spend Sponsorship"', function () { + const contextId = "0x79aF508C9698076Bc1c2DfA224f7829e9768B11E"; + let op1 = { + name: "Sponsor", + contextId, + app, + timestamp: Date.now(), + v: 5, + }; + const message1 = getMessage(op1); + op1.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message1), sponsorPrivateKey) + ) + ); + const r = apply(op1); + let resp1 = request.get(`${baseUrl}/sponsorships/${contextId}`); + resp1.json.data.appHasAuthorized.should.equal(true); + resp1.json.data.spendRequested.should.equal(false); + + let op2 = { + name: "Spend Sponsorship", + contextId: contextId.toLowerCase(), + app, + timestamp: Date.now(), + v: 5, + }; + const r2 = apply(op2); + let resp3 = request.get(`${baseUrl}/sponsorships/${contextId}`); + resp3.json.data.appHasAuthorized.should.equal(true); + resp3.json.data.spendRequested.should.equal(true); + appsColl.document(app).usedSponsorships.should.equal(1); + }); + + it('clients should be able to "Spend Sponsorship" first then apps "Sponsor"', function () { + const contextId = "0x79aF508C9698076Bc1c2DfA224f7829e9768B11D"; + let op1 = { + name: "Spend Sponsorship", + contextId, + app, + timestamp: Date.now(), + v: 5, + }; + apply(op1); + let resp1 = request.get(`${baseUrl}/sponsorships/${contextId}`); + resp1.json.data.spendRequested.should.equal(true); + resp1.json.data.appHasAuthorized.should.equal(false); + + let op2 = { + name: "Sponsor", + contextId: contextId.toLowerCase(), + app, + timestamp: Date.now(), + v: 5, + }; + const message3 = getMessage(op2); + op2.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message3), sponsorPrivateKey) + ) + ); + apply(op2); + let resp3 = request.get( + `${baseUrl}/sponsorships/${contextId.toLowerCase()}` + ); + resp3.json.data.appHasAuthorized.should.equal(true); + resp3.json.data.spendRequested.should.equal(true); + appsColl.document(app).usedSponsorships.should.equal(2); + }); + + it("should reject the duplicate sponsor requests which are received recently", function () { + const contextId = "0x79aF508C9698076Bc1c2DfA224f7829e9768B11D"; + let op = { + name: "Sponsor", + contextId, + app, + timestamp: Date.now(), + v: 5, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), sponsorPrivateKey) + ) + ); + let resp = request.post(`${baseUrl}/operations`, { + body: op, + json: true, + }); + resp.status.should.equal(403); + resp.json.errorNum.should.equal(errors.SPONSOR_REQUESTED_RECENTLY); + }); + + it("should reject the sponsor requests which are already sponsored", function () { + const contextId = "0x79aF508C9698076Bc1c2DfA224f7829e9768B11F"; + // insert dummy sponsorship + sponsorshipsColl.insert({ + _from: "users/0", + _to: `apps/${app}`, + appId: contextId, + appHasAuthorized: true, + spendRequested: true, + timestamp: Date.now(), + }); + + let op = { + name: "Sponsor", + contextId, + app, + timestamp: Date.now(), + v: 5, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), sponsorPrivateKey) + ) + ); + let resp = request.post(`${baseUrl}/operations`, { + body: op, + json: true, + }); + resp.status.should.equal(403); + resp.json.errorNum.should.equal(errors.SPONSORED_BEFORE); + }); + + it("return not sponsored for the unlinked and not sponsored contextid", function () { + const contextId = "0x51E4093bb8DA34AdD694A152635bE8e38F4F1a29"; + let resp = request.get( + `${baseUrl}/verifications/${app}/${contextId.toLowerCase()}`, + { + qs: { + signed: "eth", + timestamp: "seconds", + }, + json: true, + } + ); + resp.json.errorNum.should.equal(errors.NOT_SPONSORED); + }); + + it("return contextid not found for the unlinked and sponsored contextid", function () { + const contextId = "0x51E4093bb8DA34AdD694A152635bE8e38F4F1a29"; + let op = { + name: "Sponsor", + contextId: contextId.toLowerCase(), + app, + timestamp: Date.now(), + v: 5, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), sponsorPrivateKey) + ) + ); + apply(op); + + let resp = request.get( + `${baseUrl}/verifications/${app}/${contextId.toLowerCase()}`, + { + qs: { + signed: "eth", + timestamp: "seconds", + }, + json: true, + } + ); + resp.json.errorNum.should.equal(errors.CONTEXTID_NOT_FOUND); + }); + + it('when the client sends "Link ContextId", the app should be able to get verification (the client should check sponsorships status)', function () { + const contextId = "0x51E4093bb8DA34AdD694A152635bE8e38F4F1a30"; + + const op = { + name: "Link ContextId", + context: contextName, + id: u1.id, + contextId: contextId.toLowerCase(), + timestamp: Date.now(), + v: 5, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), u1.secretKey) + ) + ); + apply(op); + let resp = request.get( + `${baseUrl}/verifications/${app}/${contextId.toLowerCase()}`, + { + qs: { + signed: "eth", + timestamp: "seconds", + }, + json: true, + } + ); + resp.json.data.unique.should.equal(true); + }); + }); + + describe("Checking verifications signatures", function () { + it("nacl signature", function () { + const contextId = "0x51E4093bb8DA34AdD694A152635bE8e38F4F1a30"; + + let resp = request.get( + `${baseUrl}/verifications/${app}/${contextId.toLowerCase()}`, + { + qs: { + signed: "nacl", + }, + json: true, + } + ); + resp.status.should.equal(200); + resp.json.data.unique.should.equal(true); + const message = + resp.json.data.app + "," + resp.json.data.contextIds.join(","); + nacl.sign.detached + .verify( + strToUint8Array(message), + b64ToUint8Array(resp.json.data.sig), + b64ToUint8Array(resp.json.data.publicKey) + ) + .should.equal(true); + }); + + it("eth signature", function () { + const contextId = "0x51E4093bb8DA34AdD694A152635bE8e38F4F1a30"; + + let resp = request.get( + `${baseUrl}/verifications/${app}/${contextId.toLowerCase()}`, + { + qs: { + signed: "eth", + }, + json: true, + } + ); + resp.status.should.equal(200); + resp.json.data.unique.should.equal(true); + + let message = + pad32(resp.json.data.app) + + resp.json.data.contextIds.map(addressToBytes32).join(""); + message = Buffer.from(message, "binary").toString("hex"); + message = new Uint8Array( + createKeccakHash("keccak256").update(message, "hex").digest() + ); + + let signature = resp.json.data.sig.r + resp.json.data.sig.s; + signature = new Uint8Array(Buffer.from(signature, "hex")); + + const publicKey = new Uint8Array( + Buffer.from(resp.json.data.publicKey, "hex") + ); + + secp256k1.ecdsaVerify(signature, message, publicKey).should.equal(true); + }); + + + it('should throw error because of XOR(id, contextId)', function () { + const contextId = "0x79aF508C9698076Bc1c2DfA224f7829e9768B11E"; + let op = { + name: "Sponsor", + app: "idchain", + timestamp: Date.now(), + id: u1.id, + contextId: contextId, + v: 5 + } + + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + + let resp = request.post(`${baseUrl}/operations`, { + body: op, + json: true, + }); + + resp.status.should.equal(400); + + }) + + it('should accept and apply new sponsor operation without contextId', function () { + let op = { + name: "Sponsor", + app: app, + timestamp: Date.now(), + id: u7.id, + v: 5 + } + + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u7.secretKey)) + ); + + const resp = apply(op); + console.log(resp) + resp.json.state.should.equal("applied"); + + }) + + + + }); +}); diff --git a/web_services/foxx/brightid5/tests/replay_attack.js b/web_services/foxx/brightid5/tests/replay_attack.js new file mode 100755 index 00000000..dc392d5f --- /dev/null +++ b/web_services/foxx/brightid5/tests/replay_attack.js @@ -0,0 +1,115 @@ +"use strict"; + +const stringify = require("fast-json-stable-stringify"); +const arango = require("@arangodb").db; +const { getMessage } = require("../operations"); +const errors = require("../errors"); +const request = require("@arangodb/request"); +const nacl = require("tweetnacl"); +nacl.setPRNG(function (x, n) { + for (let i = 0; i < n; i++) { + x[i] = Math.floor(Math.random() * 256); + } +}); +const { + strToUint8Array, + uInt8ArrayToB64, + b64ToUrlSafeB64, + hash, +} = require("../encoding"); +const db = require("../db.js"); + +const { baseUrl } = module.context; +const applyBaseUrl = baseUrl.replace("/brightid5", "/apply5"); + +const connectionsColl = arango._collection("connections"); +const groupsColl = arango._collection("groups"); +const usersInGroupsColl = arango._collection("usersInGroups"); +const usersColl = arango._collection("users"); +const operationsColl = arango._collection("operations"); + +const chai = require("chai"); +const should = chai.should(); + +const u1 = nacl.sign.keyPair(); +const u2 = nacl.sign.keyPair(); +const u3 = nacl.sign.keyPair(); +[u1, u2, u3].map((u) => { + u.signingKey = uInt8ArrayToB64(Object.values(u.publicKey)); + u.id = b64ToUrlSafeB64(u.signingKey); +}); + +describe("replay attack on operations", function () { + before(function () { + usersColl.truncate(); + connectionsColl.truncate(); + groupsColl.truncate(); + usersInGroupsColl.truncate(); + operationsColl.truncate(); + db.createUser(u1.id, u1.signingKey); + db.createUser(u2.id, u2.signingKey); + db.createUser(u3.id, u3.signingKey); + }); + after(function () { + usersColl.truncate(); + connectionsColl.truncate(); + groupsColl.truncate(); + usersInGroupsColl.truncate(); + operationsColl.truncate(); + }); + + it("should not be able to add an operation twice", function () { + const timestamp = Date.now(); + + let op = { + name: "Add Connection", + id1: u1.id, + id2: u2.id, + timestamp, + v: 5, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + op.sig2 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + const resp1 = request.post(`${baseUrl}/operations`, { + body: op, + json: true, + }); + resp1.status.should.equal(200); + const h = hash(message); + resp1.json.data.hash.should.equal(h); + + op = operationsColl.document(h); + delete op._rev; + delete op._id; + delete op._key; + delete op.hash; + delete op.state; + op.blockTime = op.timestamp; + const resp2 = request.put(`${applyBaseUrl}/operations/${h}`, { + body: op, + json: true, + }); + resp2.json.success.should.equal(true); + resp2.json.state.should.equal("applied"); + delete op.blockTime; + + const resp3 = request.post(`${baseUrl}/operations`, { + body: op, + json: true, + }); + resp3.status.should.equal(403); + resp3.json.errorNum.should.equal(errors.OPERATION_APPLIED_BEFORE); + op.blockTime = op.timestamp; + + const resp4 = request.put(`${applyBaseUrl}/operations/${h}`, { + body: op, + json: true, + }); + resp4.json.result.errorNum.should.equal(errors.OPERATION_APPLIED_BEFORE); + }); +}); diff --git a/web_services/foxx/brightid5/tests/sponsorship.js b/web_services/foxx/brightid5/tests/sponsorship.js new file mode 100644 index 00000000..e71e06a5 --- /dev/null +++ b/web_services/foxx/brightid5/tests/sponsorship.js @@ -0,0 +1,150 @@ +"use strict"; + +const db = require("../db.js"); +const arango = require("@arangodb").db; +const errors = require("../errors.js"); +const nacl = require("tweetnacl"); +nacl.setPRNG(function (x, n) { + for (let i = 0; i < n; i++) { + x[i] = Math.floor(Math.random() * 256); + } +}); + +const { b64ToUrlSafeB64, uInt8ArrayToB64 } = require("../encoding"); + +const usersColl = arango._collection("users"); +const appsColl = arango._collection("apps"); +const sponsorshipsColl = arango._collection("sponsorships"); +const verificationsColl = arango._collection("verifications"); +const variablesColl = arango._collection("variables"); +const contextsColl = arango._collection("contexts"); + +const chai = require("chai"); +const should = chai.should(); + +const u1 = nacl.sign.keyPair(); +const u2 = nacl.sign.keyPair(); + +const contextName = "testapp"; +const app = "testapp"; + +let { publicKey: sponsorPublicKey, secretKey: sponsorPrivateKey } = + nacl.sign.keyPair(); +let { secretKey: linkAESKey } = nacl.sign.keyPair(); + +describe("New sponsorship routine", function () { + before(function () { + usersColl.truncate(); + appsColl.truncate(); + sponsorshipsColl.truncate(); + verificationsColl.truncate(); + contextsColl.truncate(); + + [u1, u2].forEach((u) => { + u.signingKey = uInt8ArrayToB64(Object.values(u.publicKey)); + u.id = b64ToUrlSafeB64(u.signingKey); + db.createUser(u.id, Date.now()); + }); + + contextsColl.insert({ + _key: contextName, + collection: contextName, + linkAESKey: uInt8ArrayToB64(Object.values(linkAESKey)), + idsAsHex: true, + }); + + appsColl.insert({ + _key: app, + context: contextName, + totalSponsorships: 5, + verification: "meets.rank>1 and bitu.score>2", + idsAsHex: true, + sponsorPublicKey: uInt8ArrayToB64(Object.values(sponsorPublicKey)), + }); + + const hashes = JSON.parse( + variablesColl.document("VERIFICATIONS_HASHES").hashes + ); + const block = Math.max(...Object.keys(hashes)); + + verificationsColl.insert({ + name: "bitu", + user: u1.id, + score: 3, + block, + }); + + verificationsColl.insert({ + name: "meets", + user: u1.id, + rank: 2, + block, + }); + + verificationsColl.insert({ + name: "meets", + user: u2.id, + rank: 2, + block, + }); + }); + after(function () { + usersColl.truncate(); + appsColl.truncate(); + sponsorshipsColl.truncate(); + verificationsColl.truncate(); + variablesColl.truncate(); + contextsColl.truncate(); + }); + + describe("Unit tests", function () { + describe("db.isVerifiedFor", function () { + it("should return true for the expected expr", function () { + const sampleExpr = `meets.rank>1 and bitu.score>2`; + db.isVerifiedFor(u1.id, sampleExpr).should.equal(true); + }); + it("should return false for the expected expr", function () { + const sampleExpr = `meets.rank>1 and bitu.score>3`; + db.isVerifiedFor(u1.id, sampleExpr).should.equal(false); + }); + it("should return false for the expected expr", function () { + const sampleExpr = `meets.rank>1 and bitu.score>2`; + db.isVerifiedFor(u2.id, sampleExpr).should.equal(false); + }); + }); + describe("db.sponor", function () { + it("should accept new sponsor operations", function () { + db.isSponsored(u1.id).should.equal(false); + const operation = { + id: u1.id, + app: app, + timestamp: Date.now(), + }; + db.sponsor(operation); + db.isSponsored(u1.id).should.equal(true); + }); + it("should reject sponsor operation with verification error", function () { + db.isSponsored(u2.id).should.equal(false); + const operation = { + id: u2.id, + app: app, + timestamp: Date.now(), + }; + (() => { + db.sponsor(operation) + }).should.throw(errors.NOT_VERIFIED); + }); + it("should reject sponsor operation with already sponsored error", function () { + db.isSponsored(u1.id).should.equal(true); + const operation = { + id: u1.id, + app: app, + timestamp: Date.now(), + }; + (() => { + db.sponsor(operation) + }).should.throw(errors.SPONSORED_BEFORE); + }); + }); + }); +}); diff --git a/web_services/foxx/brightid5/tests/time_window.js b/web_services/foxx/brightid5/tests/time_window.js new file mode 100755 index 00000000..fdd306f8 --- /dev/null +++ b/web_services/foxx/brightid5/tests/time_window.js @@ -0,0 +1,71 @@ +"use strict"; + +const operations = require("../operations.js"); +const db = require("../db.js"); +const errors = require("../errors"); +const arango = require("@arangodb").db; + +const usersColl = arango._collection("users"); +const connectionsColl = arango._collection("connections"); +const verificationsColl = arango._collection("verifications"); +const operationCountersColl = arango._collection("operationCounters"); +const variablesColl = arango._collection("variables"); + +const chai = require("chai"); +const should = chai.should(); + +describe("time window", function () { + before(function () { + usersColl.truncate(); + connectionsColl.truncate(); + verificationsColl.truncate(); + usersColl.insert({ _key: "a" }); + usersColl.insert({ _key: "b" }); + usersColl.insert({ _key: "c" }); + const hashes = JSON.parse(variablesColl.document("VERIFICATIONS_HASHES").hashes); + const block = Math.max(...Object.keys(hashes)); + verificationsColl.insert({ name: "BrightID", user: "a", block }); + operationCountersColl.truncate(); + }); + after(function () { + usersColl.truncate(); + connectionsColl.truncate(); + verificationsColl.truncate(); + operationCountersColl.truncate(); + }); + it("should get error after limit", function () { + operations.checkLimits({ name: "Add Group", id1: "a" }, 100, 2); + operations.checkLimits({ name: "Remove Group", id: "a" }, 100, 2); + (() => { + operations.checkLimits({ name: "Add Membership", id: "a" }, 100, 2); + }).should.throw(errors.TooManyOperationsError); + }); + it("unverified users should have shared limit", function () { + operations.checkLimits({ name: "Add Group", id1: "b" }, 100, 2); + operations.checkLimits({ name: "Add Group", id1: "c" }, 100, 2); + (() => { + operations.checkLimits({ name: "Add Membership", id: "b" }, 100, 2); + }).should.throw(errors.TooManyOperationsError); + }); + it("connecting to first verified user should set parent", function () { + db.addConnection("a", "c", 1); + usersColl.document("c").parent.should.equal("a"); + }); + it("unverified users with parent should have different limit", function () { + (() => { + operations.checkLimits({ name: "Add Membership", id: "b" }, 100, 2); + }).should.throw(errors.TooManyOperationsError); + operations.checkLimits({ name: "Add Group", id1: "c" }, 100, 2); + operations.checkLimits({ name: "Add Group", id1: "c" }, 100, 2); + (() => { + operations.checkLimits({ name: "Add Membership", id: "c" }, 100, 2); + }).should.throw(errors.TooManyOperationsError); + }); + it("every app should have different limit", function () { + operations.checkLimits({ name: "Sponsor", app: "app1" }, 100, 2); + operations.checkLimits({ name: "Sponsor", app: "app1" }, 100, 2); + (() => { + operations.checkLimits({ name: "Sponsor", app: "app1" }, 100, 2); + }).should.throw(errors.TooManyOperationsError); + }); +}); diff --git a/web_services/foxx/brightid6.zip b/web_services/foxx/brightid6.zip deleted file mode 100644 index 8948b3cf..00000000 Binary files a/web_services/foxx/brightid6.zip and /dev/null differ diff --git a/web_services/foxx/brightid6/WISchnorrClient.js b/web_services/foxx/brightid6/WISchnorrClient.js new file mode 100644 index 00000000..722ad29d --- /dev/null +++ b/web_services/foxx/brightid6/WISchnorrClient.js @@ -0,0 +1,144 @@ +/** + -- WISchnorrClient.js -- + Author : Christof Torres + Date : September 2016 +**/ + +var CryptoJS = require("crypto-js"); +var BigInteger = require("jsbn").BigInteger; +const { modPow } = require("./encoding"); + +BigInteger.prototype.remoteModPow = function remoteModPow(exp, b) { + return modPow(this, exp, b); +}; + +function sha256(s) { + return new BigInteger(CryptoJS.SHA256(s).toString(CryptoJS.enc.Hex), 16); +} + +/* Initializes the WISchnorClient based on a given public key */ +function WISchnorrClient(publicKey) { + // Discrete logarithm parameters + this.p = new BigInteger(publicKey.p); + this.q = new BigInteger(publicKey.q); + this.g = new BigInteger(publicKey.g); + // Public key + this.y = new BigInteger(publicKey.y); +} + +/* Generates a cryptographically secure random number modulo q */ +WISchnorrClient.prototype.GenerateRandomNumber = function () { + var bytes = Math.floor(Math.random() * (this.q.bitLength() / 8 - 1 + 1)) + 1; + const r = CryptoJS.lib.WordArray.random(bytes); + const rhex = CryptoJS.enc.Hex.stringify(r); + return new BigInteger(rhex, 16).mod(this.q); +}; + +/* Generates a challenge 'e' for the server */ +WISchnorrClient.prototype.GenerateWISchnorrClientChallenge = function ( + params, + info, + msg +) { + var t1 = this.GenerateRandomNumber(); + var t2 = this.GenerateRandomNumber(); + var t3 = this.GenerateRandomNumber(); + var t4 = this.GenerateRandomNumber(); + + var F = sha256(info); + // z = F^((p-1)/q) mod p + var z = F.remoteModPow(this.p.subtract(new BigInteger("1")).divide(this.q), this.p); + // alpha = a * g^t1 * y^t2 + var a = new BigInteger(params.a); + var alpha = a + .multiply(this.g.remoteModPow(t1, this.p)) + .multiply(this.y.remoteModPow(t2, this.p)) + .mod(this.p); + + // beta = b * g^t3 * z^t4 + var b = new BigInteger(params.b); + var beta = b + .multiply(this.g.remoteModPow(t3, this.p)) + .multiply(z.remoteModPow(t4, this.p)) + .mod(this.p); + + var H = sha256(alpha.toString() + beta.toString() + z.toString() + msg); + // epsilon = H mod q + var epsilon = H.mod(this.q); + + // e = eplison - t2 - t4 mod q + var e = epsilon.subtract(t2).subtract(t4).mod(this.q); + + return { e: e.toString(), t: { t1: t1, t2: t2, t3: t3, t4: t4 } }; +}; + +/* Generates a WISchnorr partially blind signature based on the response from the server */ +WISchnorrClient.prototype.GenerateWISchnorrBlindSignature = function ( + challenge, + response +) { + // rho = r + t1 mod q + var r = new BigInteger(response.r); + var rho = r.add(challenge.t1).mod(this.q); + + // omega = c + t2 mod q + var c = new BigInteger(response.c); + var omega = c.add(challenge.t2).mod(this.q); + + // sigma = s + t3 mod q + var s = new BigInteger(response.s); + var sigma = s.add(challenge.t3).mod(this.q); + + // delta = d + t4 mod q + var d = new BigInteger(response.d); + var delta = d.add(challenge.t4).mod(this.q); + + return { + rho: rho.toString(), + omega: omega.toString(), + sigma: sigma.toString(), + delta: delta.toString(), + }; +}; + +/* Verifies a WISchnorr partially blind signature */ +WISchnorrClient.prototype.VerifyWISchnorrBlindSignature = function ( + signature, + info, + msg +) { + var F = sha256(info); + // z = F^((p-1)/q) mod p + var z = F.remoteModPow(this.p.subtract(new BigInteger("1")).divide(this.q), this.p); + + // g^rho mod p + var gp = this.g.remoteModPow(new BigInteger(signature.rho), this.p); + // y^omega mod p + var yw = this.y.remoteModPow(new BigInteger(signature.omega), this.p); + // g^rho * y^omega mod p + var gpyw = gp.multiply(yw).mod(this.p); + + // g^sigma mod p + var gs = this.g.remoteModPow(new BigInteger(signature.sigma), this.p); + // z^delta mod p + var zd = z.remoteModPow(new BigInteger(signature.delta), this.p); + // g^sigma * z^delta mod p + var gszd = gs.multiply(zd).mod(this.p); + + var H = sha256(gpyw.toString() + gszd.toString() + z.toString() + msg); + // hsig = H mod q + var hsig = H.mod(this.q); + + // vsig = omega + delta mod q + var vsig = new BigInteger(signature.omega) + .add(new BigInteger(signature.delta)) + .mod(this.q); + + if (vsig.compareTo(hsig) === 0) { + return true; + } else { + return false; + } +}; + +module.exports = WISchnorrClient; diff --git a/web_services/foxx/brightid6/WISchnorrServer.js b/web_services/foxx/brightid6/WISchnorrServer.js new file mode 100644 index 00000000..74c7a733 --- /dev/null +++ b/web_services/foxx/brightid6/WISchnorrServer.js @@ -0,0 +1,161 @@ +/** + -- WISchnorrServer.js -- + Author : Christof Torres + Date : September 2016 +**/ + +var CryptoJS = require("crypto-js"); +var BigInteger = require("jsbn").BigInteger; +const { modPow } = require("./encoding"); + +BigInteger.prototype.remoteModPow = function remoteModPow(exp, b) { + return modPow(this, exp, b); +}; + +function sha256(s) { + return new BigInteger(CryptoJS.SHA256(s).toString(CryptoJS.enc.Hex), 16); +} + +/* Initializes the WISchnorServer */ +function WISchnorrServer() { + /* Discrete Logarithm Generator (DLG) */ + this.q = new BigInteger("2"); + var w = new BigInteger("2"); + + // q = 2^256 - 2^168 + 1 + this.q = this.q.pow(256); // q = 2^256 + this.q = this.q.subtract(new BigInteger("2").pow(168)); // q = 2^256 - 2^168 + this.q = this.q.add(new BigInteger("1")); // q = 2^256 - 2^168 + 1 + + // w = 2^2815 + 231 + w = w.pow(2815); // w = 2^2815 + w = w.add(new BigInteger("231")); // w = 2^2815 + 231 + + // p = 2wq + 1 + this.p = w.multiply(this.q); // p = wq + this.p = this.p.multiply(new BigInteger("2")); // p = 2wq + this.p = this.p.add(new BigInteger("1")); // p = 2wq + 1 + + // g = 2^2w mod p + this.g = new BigInteger("2").remoteModPow(w.multiply(new BigInteger("2")), this.p); +} + +/* Generates a Schnorr keypair: y = g^x mod q */ +WISchnorrServer.prototype.GenerateSchnorrKeypair = function (password) { + // Hashed password (SHA-256) + var hash = sha256(password); + + // Private key + // x = hash mod q + this.x = hash.mod(this.q); + + // Public key + // y = g^x mod p + this.y = this.g.remoteModPow(this.x, this.p); + + return { y: this.y, x: this.x }; +}; + +/* Extracts the public key of the Schnorr scheme */ +WISchnorrServer.prototype.ExtractPublicKey = function () { + return { + p: this.p.toString(), + q: this.q.toString(), + g: this.g.toString(), + y: this.y.toString(), + }; +}; + +/* Generates a cryptographically secure random number modulo q */ +WISchnorrServer.prototype.GenerateRandomNumber = function () { + var bytes = Math.floor(Math.random() * (this.q.bitLength() / 8 - 1 + 1)) + 1; + const r = CryptoJS.lib.WordArray.random(bytes); + const rhex = CryptoJS.enc.Hex.stringify(r); + return new BigInteger(rhex, 16).mod(this.q); +}; + +/* Generates the serverside private parameters and the serverside public + parameters 'a' and 'b' for the client */ +WISchnorrServer.prototype.GenerateWISchnorrParams = function (info) { + var u = this.GenerateRandomNumber(); + var s = this.GenerateRandomNumber(); + var d = this.GenerateRandomNumber(); + + var F = sha256(info); + // z = F^((p-1)/q) mod p + var z = F.remoteModPow(this.p.subtract(new BigInteger("1")).divide(this.q), this.p); + + // a = g^u mod p + var a = this.g.remoteModPow(u, this.p); + + // b = (g^s * z^d) mod p + var b = this.g.remoteModPow(s, this.p).multiply(z.remoteModPow(d, this.p)).mod(this.p); + + return { + private: { u: u, s: s, d: d }, + public: { a: a.toString(), b: b.toString() }, + }; +}; + +/* Generates the server response based on the challenge received from the client */ +WISchnorrServer.prototype.GenerateWISchnorrServerResponse = function ( + params, + e +) { + e = new BigInteger(e); + + // c = e − d mod q + var c = e.subtract(params.d).mod(this.q); + + // r = u − cx mod q + var r = params.u.subtract(c.multiply(this.x)).mod(this.q); + + return { + r: r.toString(), + c: c.toString(), + s: params.s.toString(), + d: params.d.toString(), + }; +}; + +/* Verifies a WISchnorr partially blind signature */ +WISchnorrServer.prototype.VerifyWISchnorrBlindSignature = function ( + signature, + info, + msg +) { + var F = sha256(info); + // z = F^((p-1)/q) mod p + var z = F.remoteModPow(this.p.subtract(new BigInteger("1")).divide(this.q), this.p); + + // g^rho mod p + var gp = this.g.remoteModPow(new BigInteger(signature.rho), this.p); + // y^omega mod p + var yw = this.y.remoteModPow(new BigInteger(signature.omega), this.p); + // g^rho * y^omega mod p + var gpyw = gp.multiply(yw).mod(this.p); + + // g^sigma mod p + var gs = this.g.remoteModPow(new BigInteger(signature.sigma), this.p); + // z^delta mod p + var zd = z.remoteModPow(new BigInteger(signature.delta), this.p); + // g^sigma * z^delta mod p + var gszd = gs.multiply(zd).mod(this.p); + + var H = sha256(gpyw.toString() + gszd.toString() + z.toString() + msg); + // hsig = H mod q + var hsig = H.mod(this.q); + + // vsig = omega + delta mod q + var vsig = new BigInteger(signature.omega) + .add(new BigInteger(signature.delta)) + .mod(this.q); + + if (vsig.compareTo(hsig) === 0) { + return true; + } else { + return false; + } +}; + +module.exports = WISchnorrServer; diff --git a/web_services/foxx/brightid6/apply.js b/web_services/foxx/brightid6/apply.js new file mode 100755 index 00000000..4a783e66 --- /dev/null +++ b/web_services/foxx/brightid6/apply.js @@ -0,0 +1,100 @@ +"use strict"; +const createRouter = require("@arangodb/foxx/router"); +const joi = require("joi"); +const { db: arango, ArangoError } = require("@arangodb"); +const db = require("./db"); +const operations = require("./operations"); +const schemas = require("./schemas"); +const errors = require("./errors"); + +const router = createRouter(); +module.context.use(router); +const operationsHashesColl = arango._collection("operationsHashes"); + +const handlers = { + operationsPut: function (req, res) { + const op = req.body; + const hash = req.param("hash"); + op.hash = hash; + try { + if (operationsHashesColl.exists(op.hash)) { + throw new errors.OperationAppliedBeforeError(op.hash); + } + operations.verify(op); + op.result = operations.apply(op); + op.state = "applied"; + operationsHashesColl.insert({ _key: op.hash }); + } catch (e) { + op.state = "failed"; + if (e instanceof ArangoError) { + e.arangoErrorNum = e.errorNum; + e.errorNum = errors.ARANGO_ERROR; + } + op.result = { + message: e.message || e, + stack: !(e instanceof errors.BrightIDError) ? e.stack : undefined, + errorNum: e.errorNum, + arangoErrorNum: e.arangoErrorNum, + }; + } + db.upsertOperation(op); + res.send({ success: true, state: op.state, result: op.result }); + }, +}; + +// add blockTime to operation schema +schemas.schemas.operation = joi + .alternatives() + .try( + Object.values(schemas.operations).map((op) => { + op.blockTime = joi + .number() + .required() + .description("milliseconds since epoch when the block was created"); + return joi.object(op); + }) + ) + .description( + "Send operations to idchain to be applied to BrightID nodes' databases after consensus" + ); + +router + .put("/operations/:hash", handlers.operationsPut) + .pathParam( + "hash", + joi.string().required().description("sha256 hash of the operation message") + ) + .body(schemas.schemas.operation) + .summary("Apply operation after consensus") + .description("Apply operation after consensus.") + .response(null); + +module.context.use(function (req, res, next) { + try { + next(); + } catch (e) { + if (e.cause && e.cause.isJoi) { + e.code = 400; + if ( + req._raw.url.includes("operations") && + e.cause.details && + e.cause.details.length > 0 + ) { + let msg1 = ""; + const msg2 = "invalid operation name"; + e.cause.details.forEach((d) => { + if (!d.message.includes('"name" must be one of')) { + msg1 += `${d.message}, `; + } + }); + e.message = msg1 || msg2; + } + } + console.group("Error returned"); + console.log("url:", req._raw.requestType, req._raw.url); + console.log("error:", e); + console.log("body:", req.body); + console.groupEnd(); + res.throw(e.code || 500, e); + } +}); diff --git a/web_services/foxx/brightid6/db.js b/web_services/foxx/brightid6/db.js new file mode 100755 index 00000000..2dbb6e7e --- /dev/null +++ b/web_services/foxx/brightid6/db.js @@ -0,0 +1,1157 @@ +"use strict"; +const { query, db, aql } = require("@arangodb"); +const _ = require("lodash"); +const { + urlSafeB64ToB64, + hash, + priv2addr, + getNaclKeyPair, + getEthKeyPair, + getConsensusSenderAddress, +} = require("./encoding"); +const errors = require("./errors"); +const wISchnorrServer = require("./WISchnorrServer"); +const parser = require("expr-eval").Parser; + +const connectionsColl = db._collection("connections"); +const connectionsHistoryColl = db._collection("connectionsHistory"); +const groupsColl = db._collection("groups"); +const usersInGroupsColl = db._collection("usersInGroups"); +const usersColl = db._collection("users"); +const appsColl = db._collection("apps"); +const sponsorshipsColl = db._collection("sponsorships"); +const operationsColl = db._collection("operations"); +const invitationsColl = db._collection("invitations"); +const verificationsColl = db._collection("verifications"); +const variablesColl = db._collection("variables"); +const cachedParamsColl = db._collection("cachedParams"); +const appIdsColl = db._collection("appIds"); + +function connect(op) { + let { + id1: key1, + id2: key2, + level, + reportReason, + replacedWith, + requestProof, + timestamp, + } = op; + + if (key1 == key2) { + throw new errors.ForbiddenConnectionError(); + } + + const _from = "users/" + key1; + const _to = "users/" + key2; + if (level == "recovery") { + const tf = connectionsColl.firstExample({ _from: _to, _to: _from }); + if (!tf || !["already known", "recovery"].includes(tf.level)) { + throw new errors.IneligibleRecoveryConnectionError(); + } + } + + // create user by adding connection if it's not created + // todo: we should prevent non-verified users from creating new users by making connections. + const u1 = usersColl.exists(key1) + ? usersColl.document(key1) + : createUser(key1, timestamp); + const u2 = usersColl.exists(key2) + ? usersColl.document(key2) + : createUser(key2, timestamp); + + // set the first verified user that connect to a user as its parent + let verifications = userVerifications(key1); + if (!u2.parent && verifications.map((v) => v.name).includes("BrightID")) { + usersColl.update(u2, { parent: key1 }); + } + + const conn = connectionsColl.firstExample({ _from, _to }); + + if (level != "reported") { + // clear reportReason for levels other than reported + reportReason = null; + } + if (level != "reported" || reportReason != "replaced") { + // clear replacedWith for levels other than reported + // and reportReason other than replaced + replacedWith = null; + } + if (replacedWith && !usersColl.exists(replacedWith)) { + throw new errors.UserNotFoundError(replacedWith); + } + + connectionsHistoryColl.insert({ + _from, + _to, + level, + reportReason, + replacedWith, + requestProof, + timestamp, + }); + + if (!conn) { + connectionsColl.insert({ + _from, + _to, + level, + reportReason, + replacedWith, + requestProof, + timestamp, + initTimestamp: timestamp, + }); + } else { + connectionsColl.update(conn, { + level, + reportReason, + replacedWith, + requestProof, + timestamp, + }); + } +} + +function userConnections(userId, direction = "outbound") { + checkUserExists(userId); + let query, resIdAttr; + if (direction == "outbound") { + query = { _from: "users/" + userId }; + resIdAttr = "_to"; + } else if (direction == "inbound") { + query = { _to: "users/" + userId }; + resIdAttr = "_from"; + } + return connectionsColl + .byExample(query) + .toArray() + .map((conn) => { + const id = conn[resIdAttr].replace("users/", ""); + return { + id, + level: conn.level, + reportReason: conn.reportReason, + timestamp: conn.timestamp, + }; + }); +} + +function groupMembers(groupId) { + return usersInGroupsColl + .byExample({ + _to: "groups/" + groupId, + }) + .toArray() + .map((e) => e._from.replace("users/", "")); +} + +function userMemberships(userId) { + checkUserExists(userId); + return usersInGroupsColl + .byExample({ + _from: "users/" + userId, + }) + .toArray() + .map((ug) => { + return { + id: ug._to.replace("groups/", ""), + timestamp: ug.timestamp, + }; + }); +} + +function userInvites(userId) { + checkUserExists(userId); + return invitationsColl + .byExample({ + _from: "users/" + userId, + }) + .toArray() + .filter((invite) => { + return Date.now() - invite.timestamp < 86400000; + }) + .map((invite) => { + const groupId = invite._to.replace("groups/", ""); + return { + group: groupId, + inviter: invite.inviter, + invitee: userId, + id: hash(groupId + invite.inviter + userId + invite.timestamp), + data: invite.data, + timestamp: invite.timestamp, + }; + }); +} + +function invite(inviter, invitee, groupId, data, timestamp) { + const group = getGroup(groupId); + if (!group.admins || !group.admins.includes(inviter)) { + throw new errors.NotAdminError(); + } + + if (group.type == "family") { + checkJoiningFamily(groupId, invitee); + } + + invitationsColl.removeByExample({ + _from: "users/" + invitee, + _to: "groups/" + groupId, + }); + invitationsColl.insert({ + _from: "users/" + invitee, + _to: "groups/" + groupId, + inviter, + data, + timestamp, + }); +} + +function dismiss(dismisser, dismissee, groupId, timestamp) { + const group = getGroup(groupId); + if (!group.admins || !group.admins.includes(dismisser)) { + throw new errors.NotAdminError(); + } + deleteMembership(groupId, dismissee, timestamp); +} + +function getUser(id) { + if (!usersColl.exists(id)) { + throw new errors.UserNotFoundError(id); + } + return usersColl.document(id); +} +const checkUserExists = getUser; + +function createUser(key, timestamp) { + if (!usersColl.exists(key)) { + return usersColl.insert({ + signingKeys: [urlSafeB64ToB64(key)], + createdAt: timestamp, + _key: key, + }); + } else { + return usersColl.document(key); + } +} + +function familyGroup(key) { + return query` + FOR ug IN ${usersInGroupsColl} + FILTER ug._from == ${"users/" + key} + FOR group in ${groupsColl} + FILTER group._id == ug._to + && group.type == 'family' + && group.head NOT IN [null, ${key}] + RETURN group + `.toArray()[0]; +} + +function createGroup(groupId, key, url, type, timestamp) { + if (groupsColl.exists(groupId)) { + throw new errors.DuplicateGroupError(); + } + + const group = { + _key: groupId, + admins: [key], + url, + type, + timestamp, + }; + + if (type == "family") { + if (familyGroup(key)) { + throw new errors.AlreadyIsFamilyMember(key); + } + group.head = null; + group.vouchers = []; + } + + groupsColl.insert(group); + // Add the creator to the group now. + addUserToGroup(groupId, key, timestamp); +} + +function addAdmin(key, admin, groupId) { + const group = getGroup(groupId); + if (!group.admins || !group.admins.includes(key)) { + throw new errors.NotAdminError(); + } + if ( + !usersInGroupsColl.firstExample({ + _from: "users/" + admin, + _to: "groups/" + groupId, + }) + ) { + throw new errors.IneligibleNewAdminError(); + } + group.admins.push(admin); + groupsColl.update(group, { admins: group.admins }); +} + +function addUserToGroup(groupId, key, timestamp) { + const _from = "users/" + key; + const _to = "groups/" + groupId; + + const edge = usersInGroupsColl.firstExample({ _from, _to }); + if (!edge) { + usersInGroupsColl.insert({ _from, _to, timestamp }); + } else { + usersInGroupsColl.update(edge, { timestamp }); + } + // empty the group's vouchers after family group member changes + const group = groupsColl.document(groupId); + if (group.type == "family") { + groupsColl.update(group, { vouchers: [] }); + } +} + +function knownConnections(userId) { + return query` + FOR conn in ${connectionsColl} + FILTER conn._from == ${"users/" + userId} + && conn.level IN ['already known', 'recovery'] + FOR conn2 in ${connectionsColl} + FILTER conn2._from == conn._to + && conn2._to == conn._from + && conn2.level IN ['already known', 'recovery'] + RETURN conn._to + ` + .toArray() + .map((id) => id.replace("users/", "")); +} + +function checkJoiningFamily(groupId, userId) { + if (familyGroup(userId)) { + throw new errors.AlreadyIsFamilyMember(userId); + } + const members = groupMembers(groupId); + const conns = knownConnections(userId); + // if some of group members are unknown + if (_.intersection(members, conns).length != members.length) { + throw new errors.IneligibleFamilyMember(userId); + } +} + +function addMembership(groupId, key, timestamp) { + const group = getGroup(groupId); + const invite = invitationsColl.firstExample({ + _from: "users/" + key, + _to: "groups/" + groupId, + }); + // invites will expire after 72 hours + if (!invite || timestamp - invite.timestamp >= 259200000) { + throw new errors.NotInvitedError(); + } + // remove invite after joining to not allow reusing that + invitationsColl.remove(invite); + + if (group.type == "family") { + checkJoiningFamily(groupId, key); + } + addUserToGroup(groupId, key, timestamp); +} + +function deleteGroup(groupId, key, timestamp) { + const group = getGroup(groupId); + if (group.admins.indexOf(key) < 0) { + throw new errors.NotAdminError(); + } + + invitationsColl.removeByExample({ _to: "groups/" + groupId }); + usersInGroupsColl.removeByExample({ _to: "groups/" + groupId }); + groupsColl.remove(group); +} + +function deleteMembership(groupId, key, timestamp) { + const group = getGroup(groupId); + if (group.admins && group.admins.includes(key)) { + const admins = group.admins.filter((admin) => key != admin); + const members = groupMembers(groupId); + if (admins.length == 0 && members.length > 1) { + throw new errors.LeaveGroupError(); + } + groupsColl.update(group, { admins }); + } + usersInGroupsColl.removeByExample({ + _from: "users/" + key, + _to: "groups/" + groupId, + }); + // empty the group's vouchers after family group member changes + if (group.type == "family") { + if (group.head == key) { + groupsColl.update(group, { head: null, vouchers: [] }); + } else { + groupsColl.update(group, { vouchers: [] }); + } + } +} + +function getCachedParams(pub) { + const d = cachedParamsColl.firstExample({ public: pub }); + if (!d) { + throw new errors.CachedParamsNotFound(); + } + return d; +} + +function getApp(app) { + if (!appsColl.exists(app)) { + throw new errors.AppNotFoundError(app); + } + return appsColl.document(app); +} + +function getApps() { + return appsColl.all().toArray(); +} + +function appToDic(app) { + return { + id: app._key, + name: app.name, + context: app.context, + verifications: + app.verifications && app.verifications.length > 0 + ? app.verifications + : [app.verification], + verificationUrl: app.verificationUrl, + logo: app.logo, + url: app.url, + assignedSponsorships: app.totalSponsorships, + unusedSponsorships: app.totalSponsorships - (app.usedSponsorships || 0), + testing: app.testing, + idsAsHex: app.idsAsHex, + usingBlindSig: app.usingBlindSig, + verificationExpirationLength: app.verificationExpirationLength, + sponsorPublicKey: app.sponsorPublicKey, + nodeUrl: app.nodeUrl, + soulbound: app.soulbound, + callbackUrl: app.callbackUrl, + sponsoring: app.sponsoring, + }; +} + +function userVerifications(userId) { + checkUserExists(userId); + let verifications; + if (variablesColl.exists("VERIFICATIONS_HASHES")) { + let hashes = variablesColl.document("VERIFICATIONS_HASHES").hashes; + hashes = JSON.parse(hashes); + // const snapshotPeriod = hashes[1]['block'] - hashes[0]['block'] + // const lastBlock = variablesColl.document('LAST_BLOCK').value; + // // We want verifications from the second-most recently generated snapshot + // // prior to LAST_BLOCK. We use this approach to ensure all synced nodes return + // // verifications from same block regardless of how fast they are in processing + // // new generated snapshots and adding new verifications to database. + // let block; + // if (lastBlock > hashes[1]['block'] + snapshotPeriod) { + // block = hashes[1]['block']; + // } else { + // block = hashes[0]['block']; + // } + + // rollback consensus-based block selection temporarily to ensure faster verification + const block = Math.max( + ...Object.keys(hashes).map((block) => parseInt(block)) + ); + verifications = verificationsColl + .byExample({ user: userId, block }) + .toArray(); + } else { + verifications = verificationsColl.byExample({ user: userId }).toArray(); + } + verifications.forEach((v) => { + delete v._key; + delete v._id; + delete v._rev; + delete v.user; + }); + return verifications.filter((v) => !v.expression); +} + +function getRecoveryPeriods(allConnections, user, now) { + const recoveryPeriods = []; + const history = allConnections.filter((c) => c.id == user); + let open = false; + let period = {}; + for (let i = 0; i < history.length; i++) { + if (history[i].level == "recovery" && !open) { + open = true; + period["start"] = history[i].timestamp; + } else if (history[i].level != "recovery" && open) { + period["end"] = history[i].timestamp; + recoveryPeriods.push(period); + period = {}; + open = false; + } + } + if (open) { + period["end"] = now; + recoveryPeriods.push(period); + } + return recoveryPeriods; +} + +function isActiveRecovery(recoveryPeriods, firstDayBorder, aWeek, aWeekBorder) { + // a user is an active recovery connection if there is a period that + // is not closed yet or closed in recent 7 days, + // and the period was more 7 days long or started in the first day + for (const period of recoveryPeriods) { + if ( + period.end > aWeekBorder && + (period.end - period.start > aWeek || period.start < firstDayBorder) + ) { + return true; + } + } + return false; +} + +function getActiveAfter(recoveryPeriods, firstDayBorder, aWeek, now) { + // a user will become an active recovery connection if + // it is in recovery level just now but the period is not 7 days long yet + // and not started in the first day + const lastPeriod = recoveryPeriods[recoveryPeriods.length - 1]; + if ( + lastPeriod.end == now && + lastPeriod.end - lastPeriod.start < aWeek && + lastPeriod.start > firstDayBorder + ) { + return aWeek - (lastPeriod.end - lastPeriod.start); + } + return 0; +} + +function getActiveBefore( + recoveryPeriods, + firstDayBorder, + aWeek, + aWeekBorder, + now +) { + // an active recovery connection will become inactive if + // it's an active recovery connection now but is not in recovery level anymore + for (const period of recoveryPeriods) { + if ( + period.end > aWeekBorder && + (period.end - period.start > aWeek || period.start < firstDayBorder) + ) { + if (period.end == now) { + // if it's not in recovery level now + return 0; + } else { + return period.end - aWeekBorder; + } + } + } + return 0; +} + +function getRecoveryConnections(userId) { + const res = []; + const allConnections = connectionsHistoryColl + .byExample({ + _from: "users/" + userId, + }) + .toArray() + .map((c) => { + return { + id: c._to.replace("users/", ""), + level: c.level, + timestamp: c.timestamp, + }; + }); + allConnections.sort((c1, c2) => c1.timestamp - c2.timestamp); + const recoveryConnections = allConnections.filter( + (conn) => conn.level == "recovery" + ); + if (recoveryConnections.length == 0) { + return res; + } + + const now = Date.now(); + const firstDayBorder = recoveryConnections[0].timestamp + 24 * 60 * 60 * 1000; + const aWeek = 7 * 24 * 60 * 60 * 1000; + const aWeekBorder = Date.now() - aWeek; + // find all users that were selected as recovery in user's connection history + const recoveryIds = new Set(recoveryConnections.map((conn) => conn.id)); + + // 1) New recovery connections can participate in resetting signing key, + // one week after being set as recovery connection. This limit is not + // applied to recovery connections that users set for the first time. + // 2) Removed recovery connections can continue participating in resetting + // signing key, for one week after being removed from recovery connections + for (let id of recoveryIds) { + // find the periods that this user was recovery + const recoveryPeriods = getRecoveryPeriods(allConnections, id, now); + // find if this user is an active recovery connection + const isActive = isActiveRecovery( + recoveryPeriods, + firstDayBorder, + aWeek, + aWeekBorder + ); + // for not active recovery connections, find how long it takes to be activated + const activeAfter = isActive + ? 0 + : getActiveAfter(recoveryPeriods, firstDayBorder, aWeek, now); + // for active recovery connections, find how long it takes to be inactivated + const activeBefore = isActive + ? getActiveBefore( + recoveryPeriods, + firstDayBorder, + aWeek, + aWeekBorder, + now + ) + : 0; + + if (isActive || activeAfter > 0 || activeBefore > 0) { + res.push({ id, isActive, activeBefore, activeAfter }); + } + } + return res; +} + +function setSigningKey(signingKey, key, timestamp) { + usersColl.update(key, { + signingKeys: [signingKey], + updateTime: timestamp, + }); + + // remove pending invites, because they can not be decrypted anymore by the new signing key + invitationsColl.removeByExample({ + _from: "users/" + key, + }); +} + +function getSponsorship(appUserId) { + const sponsorship = sponsorshipsColl.firstExample({ appId: appUserId }); + if (!sponsorship) { + throw new errors.AppUserIdNotFoundError(appUserId); + } + return sponsorship; +} + +function isSponsored(key) { + return sponsorshipsColl.firstExample({ _from: "users/" + key }) != null; +} + +function sponsor(op) { + const app = appsColl.document(op.app); + + if (op.id) { + //check app verifications and user verifications + const wholeAppVerifications = [...app.verifications || [], app.verification].filter((v) => v && v.length > 0); + const canBeSponsored = wholeAppVerifications.some((v) => isVerifiedFor(op.id, v)); + if (!canBeSponsored) { + throw new errors.NotVerifiedError(app._key, ''); + } + const sponsorship = sponsorshipsColl.firstExample({ + _to: `apps/${op.app}`, + _from: `users/${op.id}`, + }); + if (sponsorship) { + throw new errors.SponsoredBeforeError(); + } + + sponsorshipsColl.insert({ + _from: `users/${op.id}`, + _to: "apps/" + op.app, + appHasAuthorized: true, + timestamp: op.timestamp, + appId: null + }); + appsColl.update(app, { usedSponsorships: (app.usedSponsorships || 0) + 1 }); + + //! TODO: deprecated + } else { + if ( + op.name == "Sponsor" && + app.totalSponsorships - (app.usedSponsorships || 0) < 1 + ) { + throw new errors.UnusedSponsorshipsError(op.app); + } + + if (app.idsAsHex) { + if (!isEthereumAddress(op.appUserId)) { + throw new errors.InvalidAppUserIdError(op.appUserId); + } + } + + const appUserId = app.idsAsHex ? op.appUserId.toLowerCase() : op.appUserId; + const sponsorship = sponsorshipsColl.firstExample({ + _to: `apps/${op.app}`, + appId: appUserId, + }); + if (!sponsorship) { + sponsorshipsColl.insert({ + _from: "users/0", + _to: "apps/" + op.app, + // it will expire after 1 hour + expireDate: Math.ceil(Date.now() / 1000 + 60 * 60), + appId: appUserId, + appHasAuthorized: op.name == "Sponsor" ? true : false, + spendRequested: op.name == "Spend Sponsorship" ? true : false, + timestamp: op.timestamp, + }); + return; + } + + if (sponsorship.appHasAuthorized && sponsorship.spendRequested) { + throw new errors.SponsoredBeforeError(); + } + + if (op.name == "Sponsor" && sponsorship.appHasAuthorized) { + throw new errors.AppAuthorizedBeforeError(); + } + + if (op.name == "Spend Sponsorship" && sponsorship.spendRequested) { + throw new errors.SpendRequestedBeforeError(); + } + + sponsorshipsColl.update(sponsorship, { + expireDate: null, + appHasAuthorized: true, + spendRequested: true, + timestamp: op.timestamp, + }); + + appsColl.update(app, { usedSponsorships: (app.usedSponsorships || 0) + 1 }); + } +} + +function loadOperation(key) { + return query`RETURN DOCUMENT(${operationsColl}, ${key})`.toArray()[0]; +} + +function upsertOperation(op) { + if (!operationsColl.exists(op.hash)) { + op._key = op.hash; + operationsColl.insert(op); + } else { + operationsColl.replace(op.hash, op); + } +} + +function insertAppUserIdVerification( + app, + uid, + appUserId, + verification, + roundedTimestamp +) { + const d = appIdsColl.firstExample({ uid }); + if (d) { + throw new errors.DuplicateUIDError(uid); + } + + const { idsAsHex } = appsColl.document(app); + if (idsAsHex) { + if (!isEthereumAddress(appUserId)) { + throw new errors.InvalidAppUserIdError(appUserId); + } + appUserId = appUserId.toLowerCase(); + } + appIdsColl.insert({ + app, + uid, + appId: appUserId, + verification, + roundedTimestamp, + }); +} + +function getState() { + const lastProcessedBlock = variablesColl.document("LAST_BLOCK").value; + const verificationsBlock = variablesColl.document("VERIFICATION_BLOCK").value; + const initOp = query` + FOR o in ${operationsColl} + FILTER o.state == "init" + COLLECT WITH COUNT INTO length + RETURN length + `.toArray()[0]; + const sentOp = query` + FOR o in ${operationsColl} + FILTER o.state == "sent" + COLLECT WITH COUNT INTO length + RETURN length + `.toArray()[0]; + const verificationsHashes = JSON.parse( + variablesColl.document("VERIFICATIONS_HASHES").hashes + ); + const conf = module.context.configuration; + const consensusSenderAddress = getConsensusSenderAddress(); + const { privateKey: ethPrivateKey } = getEthKeyPair(); + const { publicKey: naclSigningKey } = getNaclKeyPair(); + let wISchnorrPublic = null; + if (conf.wISchnorrPassword || conf.seed) { + const server = new wISchnorrServer(); + server.GenerateSchnorrKeypair(conf.wISchnorrPassword || conf.seed); + wISchnorrPublic = server.ExtractPublicKey(); + } + const appsLastUpdateBlock = variablesColl.exists("APPS_LAST_UPDATE") + ? variablesColl.document("APPS_LAST_UPDATE").value + : 0; + const sponsorshipsLastUpdateBlock = variablesColl.exists( + "SPONSORSHIPS_LAST_UPDATE" + ) + ? variablesColl.document("SPONSORSHIPS_LAST_UPDATE").value + : 0; + const seedGroupsLastUpdateBlock = variablesColl.exists( + "SEED_GROUPS_LAST_UPDATE" + ) + ? variablesColl.document("SEED_GROUPS_LAST_UPDATE").value + : 0; + + return { + lastProcessedBlock, + verificationsBlock, + initOp, + sentOp, + verificationsHashes, + wISchnorrPublic, + ethSigningAddress: priv2addr(ethPrivateKey), + naclSigningKey, + consensusSenderAddress, + development: conf.development, + version: module.context.manifest.version, + appsLastUpdateBlock, + sponsorshipsLastUpdateBlock, + seedGroupsLastUpdateBlock, + }; +} + +function getGroup(groupId) { + if (!groupsColl.exists(groupId)) { + throw new errors.GroupNotFoundError(groupId); + } + return groupsColl.document(groupId); +} + +function groupInvites(groupId) { + return invitationsColl + .byExample({ + _to: "groups/" + groupId, + }) + .toArray() + .filter((invite) => { + // invites will expire after 72 hours + return Date.now() - invite.timestamp < 259200000; + }) + .map((invite) => { + const invitee = invite._from.replace("users/", ""); + return { + group: groupId, + inviter: invite.inviter, + invitee, + id: hash(groupId + invite.inviter + invitee + invite.timestamp), + data: invite.data, + timestamp: invite.timestamp, + }; + }); +} + +function updateGroup(admin, groupId, url, timestamp) { + const group = getGroup(groupId); + if (!group.admins || !group.admins.includes(admin)) { + throw new errors.NotAdminError(); + } + groupsColl.update(group, { + url, + timestamp, + }); +} + +function addSigningKey(id, signingKey, timestamp) { + const signingKeys = usersColl.document(id).signingKeys || []; + if (signingKeys.indexOf(signingKey) == -1) { + signingKeys.push(signingKey); + usersColl.update(id, { signingKeys }); + } +} + +function removeSigningKey(id, signingKey) { + let signingKeys = usersColl.document(id).signingKeys || []; + signingKeys = signingKeys.filter((s) => s != signingKey); + usersColl.update(id, { signingKeys }); +} + +function removeAllSigningKeys(userId, signingKey) { + let signingKeys = usersColl.document(userId).signingKeys || []; + signingKeys = signingKeys.filter((s) => s == signingKey); + usersColl.update(userId, { signingKeys }); +} + +function vouchFamily(userId, groupId, timestamp) { + const group = getGroup(groupId); + if (group.type != "family") { + throw new errors.NotFamilyError(); + } + + // users can start vouching only after setting the head + if (!group.head) { + throw new errors.IneligibleToVouch(); + } + + const members = groupMembers(groupId); + const conns = knownConnections(userId); + // if some of group members are unknown to voucher + if (_.intersection(members, conns).length != members.length) { + throw new errors.IneligibleToVouchFor(); + } + + group.vouchers.push(userId); + groupsColl.update(group, { vouchers: group.vouchers }); +} + +function userFamiliesToVouch(userId) { + checkUserExists(userId); + const result = []; + const conns = knownConnections(userId); + const connIds = conns.map((key) => `users/${key}`); + const candidates = query` + FOR ug in ${usersInGroupsColl} + FILTER ug._from IN ${connIds} + FOR group in ${groupsColl} + FILTER group._id == ug._to + && group.type == 'family' + && group.head != null + RETURN DISTINCT group + `.toArray(); + return candidates + .filter((group) => { + const members = groupMembers(group._key); + return ( + !group.vouchers.includes(userId) && + members.length >= 2 && + groupInvites(group._key).length == 0 && + _.intersection(members, conns).length == members.length + ); + }) + .map((group) => group._key); +} + +function setFamilyHead(admin, head, groupId) { + const group = getGroup(groupId); + if (!groupMembers(groupId).includes(head)) { + throw new errors.IneligibleFamilyHead(); + } + if (familyGroup(group.head)) { + throw new errors.HeadAlreadyIsFamilyMember(); + } + if (!group.admins.includes(admin)) { + throw new errors.NotAdminError(); + } + // update head and empty the group's vouchers + groupsColl.update(group, { head, vouchers: [] }); +} + +function convertToFamily(admin, head, groupId) { + const group = getGroup(groupId); + if (group.type == "family") { + throw new errors.AlreadyIsFamilyError(); + } + + if (!group.admins.includes(admin)) { + throw new errors.NotAdminError(); + } + + const members = groupMembers(groupId); + if (!members.includes(head)) { + throw new errors.IneligibleFamilyHead(); + } + + for (const member of members) { + if (member != head && familyGroup(member)) { + throw new errors.AlreadyIsFamilyMember(member); + } + const conns = knownConnections(member); + // if some of group members are unknown + const temp = members.filter((m) => m != member); + if (_.intersection(temp, conns).length != temp.length) { + throw new errors.IneligibleFamilyMember(member); + } + } + groupsColl.update(group, { head, type: "family", vouchers: [] }); +} + +function isEthereumAddress(address) { + const re = new RegExp(/^0[xX][A-Fa-f0-9]{40}$/); + return re.test(address); +} + +function getAppUserIds(appKey, period, countOnly) { + const { verifications, verificationExpirationLength: vel } = getApp(appKey); + let roundedTimestamp; + if (period == "current") { + roundedTimestamp = vel ? parseInt(Date.now() / vel) * vel : 0; + } else if (period == "previous") { + roundedTimestamp = vel ? (parseInt(Date.now() / vel) - 1) * vel : 0; + } + + const res = []; + for (const verification of verifications) { + const data = { verification }; + const filters = [ + aql`FILTER d.app == ${appKey}`, + aql`AND d.verification == ${verification}`, + ]; + if (roundedTimestamp) { + filters.push(aql`AND d.roundedTimestamp == ${roundedTimestamp}`); + } + const joinedFilters = aql.join(filters); + + if (countOnly) { + data["count"] = query` + RETURN COUNT( + FOR d IN ${appIdsColl} + ${joinedFilters} + RETURN DISTINCT d.appId + ) + `.toArray()[0]; + } else { + data["appUserIds"] = db + ._query( + aql` + FOR d IN ${appIdsColl} + ${joinedFilters} + RETURN DISTINCT d.appId + ` + ) + .toArray(); + data["count"] = data["appUserIds"].length; + } + res.push(data); + } + return res; +} + +function sponsorRequestedRecently(op) { + const lastSponsorTimestamp = query` + FOR o in ${operationsColl} + FILTER o.name == "Sponsor" + AND o.appUserId IN ${[op.appUserId, op.appUserId.toLowerCase()]} + SORT o.timestamp ASC + RETURN o.timestamp + ` + .toArray() + .pop(); + const timeWindow = module.context.configuration.operationsTimeWindow * 1000; + return lastSponsorTimestamp && Date.now() - lastSponsorTimestamp < timeWindow; +} + +function isSponsoredByAppUserId(op) { + const sponsored = query` + FOR s in ${sponsorshipsColl} + FILTER s._to == CONCAT("apps/", ${op.app}) + AND s.appHasAuthorized == true + AND s.spendRequested == true + AND s.appId IN ${[op.appUserId, op.appUserId.toLowerCase()]} + RETURN s.appId + ` + .toArray() + .pop(); + if (sponsored) { + return true; + } + + return false; +} + +function getRequiredRecoveryNum(id) { + const user = getUser(id); + if ( + "nextRequiredRecoveryNum" in user && + user.requiredRecoveryNumSetAfter <= Date.now() + ) { + user.requiredRecoveryNum = user.nextRequiredRecoveryNum; + delete user.nextRequiredRecoveryNum; + delete user.requiredRecoveryNumSetAfter; + usersColl.replace(id, user); + } + return user.requiredRecoveryNum || 2; +} + +function setRequiredRecoveryNum(id, requiredRecoveryNum, timestamp) { + const recoveryConnections = getRecoveryConnections(id); + if (recoveryConnections.length < requiredRecoveryNum) { + throw new errors.InvalidNumberOfSignersError(); + } + + usersColl.update(id, { + nextRequiredRecoveryNum: requiredRecoveryNum, + requiredRecoveryNumSetAfter: Date.now() + 7 * 24 * 60 * 60 * 1000, + }); +} + + +function isVerifiedFor(user, verification) { + let verifications = userVerifications(user); + verifications = _.keyBy(verifications, (v) => v.name); + let verified; + try { + let expr = parser.parse(verification); + for (let v of expr.variables()) { + if (!verifications[v]) { + verifications[v] = false; + } + } + verified = expr.evaluate(verifications); + } catch (err) { + return false; + } + return verified; +} + + +module.exports = { + connect, + createGroup, + deleteGroup, + addAdmin, + addMembership, + deleteMembership, + invite, + dismiss, + userConnections, + userMemberships, + userInvites, + userVerifications, + userFamiliesToVouch, + checkUserExists, + getUser, + createUser, + groupMembers, + getApp, + getApps, + appToDic, + sponsor, + isSponsored, + getSponsorship, + loadOperation, + upsertOperation, + insertAppUserIdVerification, + setSigningKey, + getState, + getRecoveryConnections, + addSigningKey, + removeSigningKey, + removeAllSigningKeys, + getGroup, + groupInvites, + updateGroup, + getCachedParams, + vouchFamily, + setFamilyHead, + convertToFamily, + isEthereumAddress, + getAppUserIds, + sponsorRequestedRecently, + setRequiredRecoveryNum, + getRequiredRecoveryNum, + isSponsoredByAppUserId, + isVerifiedFor +}; diff --git a/web_services/foxx/brightid6/encoding.js b/web_services/foxx/brightid6/encoding.js new file mode 100755 index 00000000..cd4676eb --- /dev/null +++ b/web_services/foxx/brightid6/encoding.js @@ -0,0 +1,159 @@ +"use strict"; +const B64 = require("base64-js"); +const crypto = require("@arangodb/crypto"); +const request = require("@arangodb/request"); +const nacl = require("tweetnacl"); +const secp256k1 = require("secp256k1"); +const createKeccakHash = require("keccak"); +const BigInteger = require("jsbn").BigInteger; + +const conf = module.context.configuration; + +function uInt8ArrayToB64(array) { + const b = Buffer.from(array); + return b.toString("base64"); +} + +function b64ToUint8Array(str) { + // B64.toByteArray might return a Uint8Array, an Array or an Object depending on the platform. + // Wrap it in Object.values and new Uint8Array to make sure it's a Uint8Array. + return new Uint8Array(Object.values(B64.toByteArray(str))); +} + +function strToUint8Array(str) { + return new Uint8Array(Buffer.from(str, "ascii")); +} + +function b64ToUrlSafeB64(s) { + const alts = { + "/": "_", + "+": "-", + "=": "", + }; + return s.replace(/[/+=]/g, (c) => alts[c]); +} + +function urlSafeB64ToB64(s) { + const alts = { + _: "/", + "-": "+", + }; + s = s.replace(/[-_]/g, (c) => alts[c]); + while (s.length % 4) { + s += "="; + } + return s; +} + +function hash(data) { + const h = crypto.sha256(data); + const b = Buffer.from(h, "hex").toString("base64"); + return b64ToUrlSafeB64(b); +} + +function pad32(data) { + return data + String.fromCharCode(0).repeat(32 - data.length); +} + +function addressToBytes32(address) { + const b = Buffer.from(address.substring(2), "hex").toString("binary"); + return b; +} + +function priv2addr(priv) { + if (!priv) { + return null; + } + const publicKey = Buffer.from( + Object.values(secp256k1.publicKeyCreate(priv, false).slice(1)) + ); + return ( + "0x" + + createKeccakHash("keccak256") + .update(publicKey) + .digest() + .slice(-20) + .toString("hex") + ); +} + +function getNaclKeyPair() { + let publicKey, privateKey; + if (conf.privateKey) { + publicKey = uInt8ArrayToB64( + Object.values( + nacl.sign.keyPair.fromSecretKey(b64ToUint8Array(conf.privateKey)) + .publicKey + ) + ); + privateKey = b64ToUint8Array(conf.privateKey); + } else if (conf.seed) { + const hex32 = crypto.sha256(conf.seed); + const uint8Array = new Uint8Array(Buffer.from(hex32, "hex")); + const naclKeyPair = nacl.sign.keyPair.fromSeed(uint8Array); + publicKey = uInt8ArrayToB64(Object.values(naclKeyPair.publicKey)); + privateKey = naclKeyPair.secretKey; + } + return { publicKey, privateKey }; +} + +function getEthKeyPair() { + let publicKey, privateKey; + if (conf.ethPrivateKey) { + privateKey = new Uint8Array(Buffer.from(conf.ethPrivateKey, "hex")); + publicKey = Buffer.from( + Object.values(secp256k1.publicKeyCreate(privateKey)) + ).toString("hex"); + } else if (conf.seed) { + const hex32 = crypto.sha256(conf.seed); + privateKey = new Uint8Array(Buffer.from(hex32, "hex")); + publicKey = Buffer.from( + Object.values(secp256k1.publicKeyCreate(privateKey)) + ).toString("hex"); + } + return { publicKey, privateKey }; +} + +function getConsensusSenderAddress() { + let address = null; + if (conf.consensusSenderPrivateKey) { + const uint8ArrayPrivateKey = new Uint8Array( + Buffer.from(conf.consensusSenderPrivateKey, "hex") + ); + address = priv2addr(uint8ArrayPrivateKey); + } else if (conf.seed) { + const hex32 = crypto.sha256(conf.seed); + const uint8ArrayPrivateKey = new Uint8Array(Buffer.from(hex32, "hex")); + address = priv2addr(uint8ArrayPrivateKey); + } + return address; +} + +function modPow(a, exp, b) { + const response = request({ + method: "get", + url: "http://localhost/profile/modpow", + qs: { a: a.toString(), exp: exp.toString(), b: b.toString() }, + }); + if (response.json && response.json.data) { + return new BigInteger(response.json.data); + } else { + return a.modPow(exp, b); + } +} + +module.exports = { + uInt8ArrayToB64, + b64ToUint8Array, + strToUint8Array, + b64ToUrlSafeB64, + urlSafeB64ToB64, + hash, + pad32, + addressToBytes32, + priv2addr, + getNaclKeyPair, + getEthKeyPair, + getConsensusSenderAddress, + modPow, +}; diff --git a/web_services/foxx/brightid6/errors.js b/web_services/foxx/brightid6/errors.js new file mode 100755 index 00000000..00a29424 --- /dev/null +++ b/web_services/foxx/brightid6/errors.js @@ -0,0 +1,735 @@ +const NOT_VERIFIED = 3; +const NOT_SPONSORED = 4; +const NACL_KEY_NOT_SET = 6; +const ETH_KEY_NOT_SET = 7; +const OPERATION_NOT_FOUND = 9; +const USER_NOT_FOUND = 10; +const APP_NOT_FOUND = 12; +const INVALID_EXPRESSION = 13; +const INVALID_TESTING_KEY = 14; +const GROUP_NOT_FOUND = 17; +const INVALID_OPERATION_NAME = 18; +const INVALID_SIGNATURE = 19; +const TOO_MANY_OPERATIONS = 20; +const INVALID_OPERATION_VERSION = 21; +const INVALID_TIMESTAMP = 22; +const NOT_RECOVERY_CONNECTIONS = 23; +const INVALID_HASH = 24; +const OPERATION_APPLIED_BEFORE = 25; +const TOO_BIG_OPERATION = 26; +const ALREADY_HAS_PRIMARY_GROUP = 28; +const NEW_USER_BEFORE_FOUNDERS_JOIN = 29; +const DUPLICATE_GROUP = 31; +const INVALID_COFOUNDERS = 32; +const INELIGIBLE_NEW_ADMIN = 33; +const NOT_INVITED = 34; +const LEAVE_GROUP = 35; +const TOO_MANY_LINK_REQUEST = 37; +const UNUSED_SPONSORSHIPS = 38; +const SPONSORED_BEFORE = 39; +const SPONSOR_NOT_SUPPORTED = 40; +const NOT_ADMIN = 41; +const ARANGO_ERROR = 42; +const INELIGIBLE_RECOVERY_CONNECTION = 43; +const WISCHNORR_PASSWORD_NOT_SET = 45; +const INVALID_ROUNDED_TIMESTAMP = 46; +const DUPLICATE_SIG_REQUEST_ERROR = 47; +const HEAD_ALREADY_IS_FAMILY_MEMBER = 48; +const ALREADY_IS_FAMILY_MEMBER = 49; +const INELIGIBLE_FAMILY_MEMBER = 50; +const NOT_FAMILY = 51; +const INELIGIBLE_TO_VOUCH = 52; +const INELIGIBLE_TO_VOUCH_FOR = 53; +const INELIGIBLE_FAMILY_HEAD = 54; +const NOT_HEAD = 55; +const DUPLICATE_UID_ERROR = 56; +const DUPLICATE_SIGNERS = 57; +const WAIT_FOR_COOLDOWN = 58; +const UNACCEPTABLE_VERIFICATION = 59; +const ALREADY_IS_FAMILY = 60; +const APP_ID_NOT_FOUND = 61; +const APP_AUTHORIZED_BEFORE = 62; +const SPEND_REQUESTED_BEFORE = 63; +const INVALID_APP_ID = 64; +const CACHED_PARAMS_NOT_FOUND = 65; +const FORBIDDEN_CONNECTION = 66; +const UNSINGABLE_APP_USER_ID = 67; +const SPONSOR_REQUESTED_RECENTLY = 68; +const WRONG_NUMBER_OF_SIGNERS = 69; +const INVALID_NUMBER_OF_SIGNERS = 70; + + +class BrightIDError extends Error { + constructor() { + super(); + this.name = "BrightIDError"; + this.date = new Date(); + } +} + +class BadRequestError extends BrightIDError { + constructor() { + super(); + this.code = 400; + this.message = "Bad Request"; + } +} + +class UnauthorizedError extends BrightIDError { + constructor() { + super(); + this.code = 401; + this.message = "Unauthorized"; + } +} + +class ForbiddenError extends BrightIDError { + constructor() { + super(); + this.code = 403; + this.message = "Forbidden"; + } +} + +class NotFoundError extends BrightIDError { + constructor() { + super(); + this.code = 404; + this.message = "Not Found"; + } +} + +class TooManyRequestsError extends BrightIDError { + constructor() { + super(); + this.code = 429; + this.message = "Too Many Requests"; + } +} + +class InternalServerError extends BrightIDError { + constructor() { + super(); + this.code = 500; + this.message = "Internal Server Error"; + } +} + +class InvalidSignatureError extends UnauthorizedError { + constructor() { + super(); + this.errorNum = INVALID_SIGNATURE; + this.message = "Signature is not valid."; + } +} + +class AppNotFoundError extends NotFoundError { + constructor(app) { + super(); + this.errorNum = APP_NOT_FOUND; + this.message = `${app} app is not found.`; + this.app = app; + } +} + +class TooManyOperationsError extends TooManyRequestsError { + constructor(senders, waitingTime, timeWindow, limit) { + super(); + this.errorNum = TOO_MANY_OPERATIONS; + this.message = `More than ${limit} operations sent from ${senders.join( + ", " + )} in ${parseInt(timeWindow / 1000)} seconds. Try again after ${parseInt( + waitingTime / 1000 + )} seconds.`; + this.senders = senders; + this.waitingTime = waitingTime; + this.timeWindow = timeWindow; + this.limit = limit; + } +} + +class InvalidOperationNameError extends BadRequestError { + constructor(name) { + super(); + this.errorNum = INVALID_OPERATION_NAME; + this.message = `${name} is not a valid operation name.`; + this.name = name; + } +} + +class InvalidOperationVersionError extends BadRequestError { + constructor(v) { + super(); + this.errorNum = INVALID_OPERATION_VERSION; + this.message = `${v} is not a valid operation version.`; + this.v = v; + } +} + +class InvalidOperationTimestampError extends ForbiddenError { + constructor(timestamp) { + super(); + this.errorNum = INVALID_TIMESTAMP; + this.message = `The timestamp (${timestamp}) is in the future.`; + this.timestamp = timestamp; + } +} + +class InvalidOperationHashError extends BadRequestError { + constructor() { + super(); + this.errorNum = INVALID_HASH; + this.message = "Operation hash is not valid."; + } +} + +class NotRecoveryConnectionsError extends ForbiddenError { + constructor() { + super(); + this.errorNum = NOT_RECOVERY_CONNECTIONS; + this.message = "Signers of the request are not recovery connections."; + } +} + +class OperationNotFoundError extends NotFoundError { + constructor(hash) { + super(); + this.errorNum = OPERATION_NOT_FOUND; + this.message = `The operation ${hash} is not found.`; + this.hash = hash; + } +} + +class OperationAppliedBeforeError extends ForbiddenError { + constructor(hash) { + super(); + this.errorNum = OPERATION_APPLIED_BEFORE; + this.message = `The Operation ${hash} was applied before.`; + this.hash = hash; + } +} + +class TooBigOperationError extends ForbiddenError { + constructor(limit) { + super(); + this.errorNum = TOO_BIG_OPERATION; + this.message = `The Operation is bigger than ${limit} bytes limit.`; + this.limit = limit; + } +} + +class UserNotFoundError extends NotFoundError { + constructor(user) { + super(); + this.errorNum = USER_NOT_FOUND; + this.message = `The user ${user} is not found.`; + this.user = user; + } +} + +class GroupNotFoundError extends NotFoundError { + constructor(group) { + super(); + this.errorNum = GROUP_NOT_FOUND; + this.message = `The group ${group} is not found.`; + this.group = group; + } +} + +class NotSponsoredError extends ForbiddenError { + constructor() { + super(); + this.errorNum = NOT_SPONSORED; + this.message = `The user is not sponsored.`; + } +} + +class NotVerifiedError extends ForbiddenError { + constructor(app, expression) { + super(); + this.errorNum = NOT_VERIFIED; + this.message = `The user is not verified for this expression (${expression}) of "${app}" app.`; + this.app = app; + } +} + + + +class InvalidExpressionError extends InternalServerError { + constructor(app, expression, err) { + super(); + this.errorNum = INVALID_EXPRESSION; + this.message = `Evaluating verification expression for ${app} app failed. Expression: "${expression}", Error: ${err}`; + } +} + +class NaclKeyNotSetError extends InternalServerError { + constructor() { + super(); + this.errorNum = NACL_KEY_NOT_SET; + this.message = "BN_WS_PRIVATE_KEY is not set."; + } +} + +class EthKeyNotSetError extends InternalServerError { + constructor() { + super(); + this.errorNum = ETH_KEY_NOT_SET; + this.message = "BN_WS_ETH_PRIVATE_KEY is not set."; + } +} + +class InvalidTestingKeyError extends ForbiddenError { + constructor() { + super(); + this.errorNum = INVALID_TESTING_KEY; + this.message = "Invalid testing key."; + } +} + +class NotAdminError extends ForbiddenError { + constructor() { + super(); + this.errorNum = NOT_ADMIN; + this.message = "Requstor is not admin of the group."; + } +} + +class AlreadyHasPrimaryGroupError extends ForbiddenError { + constructor() { + super(); + this.errorNum = ALREADY_HAS_PRIMARY_GROUP; + this.message = "The user already has a primary group."; + } +} + +class NewUserBeforeFoundersJoinError extends ForbiddenError { + constructor() { + super(); + this.errorNum = NEW_USER_BEFORE_FOUNDERS_JOIN; + this.message = "New members can not join before founders join the group."; + } +} + +class DuplicateGroupError extends ForbiddenError { + constructor() { + super(); + this.errorNum = DUPLICATE_GROUP; + this.message = "Group with this id already exists."; + } +} + +class InvalidCoFoundersError extends ForbiddenError { + constructor() { + super(); + this.errorNum = INVALID_COFOUNDERS; + this.message = + "One or both of the co-founders are not connected to the founder."; + } +} + +class IneligibleNewAdminError extends ForbiddenError { + constructor() { + super(); + this.errorNum = INELIGIBLE_NEW_ADMIN; + this.message = "New admin is not member of the group."; + } +} + +class NotInvitedError extends ForbiddenError { + constructor() { + super(); + this.errorNum = NOT_INVITED; + this.message = "The user is not invited to join this group."; + } +} + +class LeaveGroupError extends ForbiddenError { + constructor() { + super(); + this.errorNum = LEAVE_GROUP; + this.message = + "Last admin can not leave the group when it still has other members."; + } +} + +class UnusedSponsorshipsError extends ForbiddenError { + constructor(app) { + super(); + this.errorNum = UNUSED_SPONSORSHIPS; + this.message = `${app} app does not have unused sponsorships.`; + this.app = app; + } +} + +class SponsoredBeforeError extends ForbiddenError { + constructor() { + super(); + this.errorNum = SPONSORED_BEFORE; + this.message = "The app generated id was sponsored before."; + } +} + +class SponsorNotSupportedError extends ForbiddenError { + constructor(app) { + super(); + this.errorNum = SPONSOR_NOT_SUPPORTED; + this.message = `This node can not relay sponsor requests for ${app} app.`; + this.app = app; + } +} + +class IneligibleRecoveryConnectionError extends ForbiddenError { + constructor() { + super(); + this.errorNum = INELIGIBLE_RECOVERY_CONNECTION; + this.message = + "Recovery level can only be selected for connections that already know you or trust you as their recovery connection."; + } +} + +class WISchnorrPasswordNotSetError extends InternalServerError { + constructor() { + super(); + this.errorNum = WISCHNORR_PASSWORD_NOT_SET; + this.message = "WISCHNORR_PASSWORD is not set in config.env."; + } +} + +class InvalidRoundedTimestampError extends ForbiddenError { + constructor(serverRoundedTimestamp, roundedTimestamp) { + super(); + this.errorNum = INVALID_ROUNDED_TIMESTAMP; + this.message = `Server calculated rounded timestamp is ${serverRoundedTimestamp}, but client sent ${roundedTimestamp}.`; + } +} + +class DuplicateSigRequestError extends ForbiddenError { + constructor() { + super(); + this.errorNum = DUPLICATE_SIG_REQUEST_ERROR; + this.message = + "Only one signature request per verification of the app in each expiration period is allowed."; + } +} + +class HeadAlreadyIsFamilyMember extends ForbiddenError { + constructor() { + super(); + this.errorNum = HEAD_ALREADY_IS_FAMILY_MEMBER; + this.message = "The previous head can't already have another family group"; + } +} + +class AlreadyIsFamilyMember extends ForbiddenError { + constructor(user) { + super(); + this.errorNum = ALREADY_IS_FAMILY_MEMBER; + this.message = `${user} already is member of a family group.`; + this.user = user; + } +} + +class IneligibleFamilyMember extends ForbiddenError { + constructor(user) { + super(); + this.errorNum = INELIGIBLE_FAMILY_MEMBER; + this.message = `${user} is not eligible to join this family group.`; + this.user = user; + } +} + +class NotFamilyError extends ForbiddenError { + constructor() { + super(); + this.errorNum = NOT_FAMILY; + this.message = "The group is not a family group."; + } +} + +class IneligibleToVouch extends ForbiddenError { + constructor() { + super(); + this.errorNum = INELIGIBLE_TO_VOUCH; + this.message = "This group is not eligible to vouch for."; + } +} + +class IneligibleToVouchFor extends ForbiddenError { + constructor() { + super(); + this.errorNum = INELIGIBLE_TO_VOUCH_FOR; + this.message = "This user is not eligible to vouch for this group."; + } +} + +class IneligibleFamilyHead extends ForbiddenError { + constructor() { + super(); + this.errorNum = INELIGIBLE_FAMILY_HEAD; + this.message = "user is not eligible to be head of the family group."; + } +} + +class NotHeadError extends ForbiddenError { + constructor() { + super(); + this.errorNum = NOT_HEAD; + this.message = "Requstor is not head of the group."; + } +} + +class DuplicateUIDError extends ForbiddenError { + constructor(uid) { + super(); + this.errorNum = DUPLICATE_UID_ERROR; + this.message = `uid ${uid} already exists.`; + } +} + +class DuplicateSignersError extends ForbiddenError { + constructor() { + super(); + this.errorNum = DUPLICATE_SIGNERS; + this.message = "Signers of the request are duplicates."; + } +} + +class WaitForCooldownError extends ForbiddenError { + constructor(signer) { + super(); + this.errorNum = WAIT_FOR_COOLDOWN; + this.message = `${signer} is still in cooling down period.`; + this.signer = signer; + } +} + +class UnacceptableVerification extends ForbiddenError { + constructor(verification, app) { + super(); + this.errorNum = UNACCEPTABLE_VERIFICATION; + this.message = `"${verification}" expression is not acceptable for the "${app}".`; + this.verification = verification; + this.app = app; + } +} + +class AlreadyIsFamilyError extends ForbiddenError { + constructor() { + super(); + this.errorNum = ALREADY_IS_FAMILY; + this.message = "The group already is a family group."; + } +} + +class AppUserIdNotFoundError extends NotFoundError { + constructor(appUserId) { + super(); + this.errorNum = APP_ID_NOT_FOUND; + this.message = `${appUserId} app generated id is not found.`; + this.appUserId = appUserId; + } +} + +class AppAuthorizedBeforeError extends ForbiddenError { + constructor() { + super(); + this.errorNum = APP_AUTHORIZED_BEFORE; + this.message = + "The app authorized a sponsorship for this app-generated id before."; + } +} + +class SpendRequestedBeforeError extends ForbiddenError { + constructor() { + super(); + this.errorNum = SPEND_REQUESTED_BEFORE; + this.message = "Spend request for this app-generated id submitted before."; + } +} + +class InvalidAppUserIdError extends BadRequestError { + constructor(appUserId) { + super(); + this.errorNum = INVALID_APP_ID; + this.message = `The appUserId "${appUserId}" is not valid.`; + this.appUserId = appUserId; + } +} + +class CachedParamsNotFound extends NotFoundError { + constructor() { + super(); + this.errorNum = CACHED_PARAMS_NOT_FOUND; + this.message = `WISchnorr params not found.`; + } +} + +class ForbiddenConnectionError extends ForbiddenError { + constructor() { + super(); + this.errorNum = FORBIDDEN_CONNECTION; + this.message = "connecting to yourself is not allowed."; + } +} + +class UnsingableAppUserIdError extends BadRequestError { + constructor(appUserId) { + super(); + this.errorNum = UNSINGABLE_APP_USER_ID; + this.message = "appUserIds longer than 32 bytes are not 'eth' signable"; + this.appUserId = appUserId; + } +} + +class SponsorRequestedRecently extends ForbiddenError { + constructor() { + super(); + this.errorNum = SPONSOR_REQUESTED_RECENTLY; + this.message = "The app has sent this sponsor request recently."; + } +} + +class WrongNumberOfSignersError extends ForbiddenError { + constructor(missedAttr, requiredRecoveryNum) { + super(); + this.errorNum = WRONG_NUMBER_OF_SIGNERS; + this.message = `${missedAttr} is missed while ${requiredRecoveryNum} signers are required.`; + this.missedAttr = missedAttr; + this.requiredRecoveryNum = requiredRecoveryNum; + } +} + +class InvalidNumberOfSignersError extends ForbiddenError { + constructor() { + super(); + this.errorNum = INVALID_NUMBER_OF_SIGNERS; + this.message = + "The number of signers should be equal or less than the number of recovery connections."; + } +} + +module.exports = { + NOT_VERIFIED, + NOT_SPONSORED, + NACL_KEY_NOT_SET, + ETH_KEY_NOT_SET, + OPERATION_NOT_FOUND, + USER_NOT_FOUND, + APP_NOT_FOUND, + INVALID_EXPRESSION, + INVALID_TESTING_KEY, + GROUP_NOT_FOUND, + INVALID_OPERATION_NAME, + INVALID_SIGNATURE, + TOO_MANY_OPERATIONS, + INVALID_OPERATION_VERSION, + INVALID_TIMESTAMP, + NOT_RECOVERY_CONNECTIONS, + INVALID_HASH, + OPERATION_APPLIED_BEFORE, + TOO_BIG_OPERATION, + ALREADY_HAS_PRIMARY_GROUP, + NEW_USER_BEFORE_FOUNDERS_JOIN, + DUPLICATE_GROUP, + INVALID_COFOUNDERS, + INELIGIBLE_NEW_ADMIN, + NOT_INVITED, + LEAVE_GROUP, + UNUSED_SPONSORSHIPS, + SPONSORED_BEFORE, + SPONSOR_NOT_SUPPORTED, + NOT_ADMIN, + ARANGO_ERROR, + INELIGIBLE_RECOVERY_CONNECTION, + WISCHNORR_PASSWORD_NOT_SET, + INVALID_ROUNDED_TIMESTAMP, + DUPLICATE_SIG_REQUEST_ERROR, + HEAD_ALREADY_IS_FAMILY_MEMBER, + ALREADY_IS_FAMILY_MEMBER, + INELIGIBLE_FAMILY_MEMBER, + NOT_FAMILY, + INELIGIBLE_TO_VOUCH, + INELIGIBLE_TO_VOUCH_FOR, + INELIGIBLE_FAMILY_HEAD, + NOT_HEAD, + DUPLICATE_UID_ERROR, + DUPLICATE_SIGNERS, + WAIT_FOR_COOLDOWN, + UNACCEPTABLE_VERIFICATION, + ALREADY_IS_FAMILY, + APP_ID_NOT_FOUND, + APP_AUTHORIZED_BEFORE, + SPEND_REQUESTED_BEFORE, + INVALID_APP_ID, + CACHED_PARAMS_NOT_FOUND, + FORBIDDEN_CONNECTION, + UNSINGABLE_APP_USER_ID, + SPONSOR_REQUESTED_RECENTLY, + WRONG_NUMBER_OF_SIGNERS, + INVALID_NUMBER_OF_SIGNERS, + BrightIDError, + BadRequestError, + InternalServerError, + TooManyRequestsError, + UnauthorizedError, + NotFoundError, + ForbiddenError, + InvalidSignatureError, + AppNotFoundError, + TooManyOperationsError, + InvalidOperationNameError, + InvalidOperationVersionError, + InvalidOperationTimestampError, + InvalidOperationHashError, + NotRecoveryConnectionsError, + OperationNotFoundError, + OperationAppliedBeforeError, + TooBigOperationError, + UserNotFoundError, + GroupNotFoundError, + NotSponsoredError, + NotVerifiedError, + InvalidExpressionError, + NaclKeyNotSetError, + EthKeyNotSetError, + InvalidTestingKeyError, + NotAdminError, + AlreadyHasPrimaryGroupError, + NewUserBeforeFoundersJoinError, + DuplicateGroupError, + InvalidCoFoundersError, + IneligibleNewAdminError, + NotInvitedError, + LeaveGroupError, + UnusedSponsorshipsError, + SponsoredBeforeError, + SponsorNotSupportedError, + IneligibleRecoveryConnectionError, + InvalidRoundedTimestampError, + WISchnorrPasswordNotSetError, + DuplicateSigRequestError, + HeadAlreadyIsFamilyMember, + AlreadyIsFamilyMember, + IneligibleFamilyMember, + NotFamilyError, + IneligibleToVouch, + IneligibleToVouchFor, + IneligibleFamilyHead, + NotHeadError, + DuplicateUIDError, + DuplicateSignersError, + WaitForCooldownError, + UnacceptableVerification, + AlreadyIsFamilyError, + AppUserIdNotFoundError, + AppAuthorizedBeforeError, + SpendRequestedBeforeError, + InvalidAppUserIdError, + CachedParamsNotFound, + ForbiddenConnectionError, + UnsingableAppUserIdError, + SponsorRequestedRecently, + WrongNumberOfSignersError, + InvalidNumberOfSignersError +}; diff --git a/web_services/foxx/brightid6/index.js b/web_services/foxx/brightid6/index.js new file mode 100755 index 00000000..548dd0b9 --- /dev/null +++ b/web_services/foxx/brightid6/index.js @@ -0,0 +1,869 @@ +"use strict"; +const stringify = require("fast-json-stable-stringify"); +const secp256k1 = require("secp256k1"); +const createKeccakHash = require("keccak"); +const BigInteger = require("jsbn").BigInteger; +const createRouter = require("@arangodb/foxx/router"); +const _ = require("lodash"); +const joi = require("joi"); +const { db: arango, ArangoError } = require("@arangodb"); +const nacl = require("tweetnacl"); +const db = require("./db"); +const schemas = require("./schemas").schemas; +const operations = require("./operations"); +const WISchnorrServer = require("./WISchnorrServer"); +const WISchnorrClient = require("./WISchnorrClient"); +const crypto = require("@arangodb/crypto"); +const { + strToUint8Array, + uInt8ArrayToB64, + hash, + pad32, + addressToBytes32, + getNaclKeyPair, + getEthKeyPair, +} = require("./encoding"); +const parser = require("expr-eval").Parser; +const errors = require("./errors"); + +const router = createRouter(); +module.context.use(router); +const usersColl = arango._collection("users"); +const operationsHashesColl = arango._collection("operationsHashes"); +const signedVerificationsColl = arango._collection("signedVerifications"); +const cachedParamsColl = arango._collection("cachedParams"); +const appIdsColl = arango._collection("appIds"); + +const MAX_OP_SIZE = 2000; + +const handlers = { + operationsPost: function (req, res) { + const op = req.body; + const message = operations.getMessage(op); + op.hash = hash(message); + if (operationsHashesColl.exists(op.hash)) { + throw new errors.OperationAppliedBeforeError(op.hash); + } else if (JSON.stringify(op).length > MAX_OP_SIZE) { + throw new errors.TooBigOperationError(MAX_OP_SIZE); + } + + // verify signature + operations.verify(op); + + // allow limited number of operations to be posted in defined time window + const timeWindow = module.context.configuration.operationsTimeWindow * 1000; + const limit = ["Sponsor", "Spend Sponsorship"].includes(op.name) + ? module.context.configuration.appsOperationsLimit + : module.context.configuration.operationsLimit; + operations.checkLimits(op, timeWindow, limit); + + op.state = "init"; + db.upsertOperation(op); + + res.send({ + data: { + hash: op.hash, + }, + }); + }, + + operationGet: function (req, res) { + const hash = req.param("hash"); + const op = db.loadOperation(hash); + if (op) { + res.send({ + data: { + state: op.state, + result: op.result, + }, + }); + } else { + throw new errors.OperationNotFoundError(hash); + } + }, + + userConnectionsGet: function (req, res) { + const id = req.param("id"); + const direction = req.param("direction"); + res.send({ + data: { + connections: db.userConnections(id, direction), + }, + }); + }, + + userVerificationsGet: function (req, res) { + const id = req.param("id"); + res.send({ + data: { + verifications: db.userVerifications(id), + }, + }); + }, + + userInvitesGet: function (req, res) { + const id = req.param("id"); + res.send({ + data: { + invites: db.userInvites(id), + }, + }); + }, + + userMembershipsGet: function (req, res) { + const id = req.param("id"); + res.send({ + data: { + memberships: db.userMemberships(id), + }, + }); + }, + + userFamiliesToVouchGet: function (req, res) { + const id = req.param("id"); + res.send({ + data: { + families: db.userFamiliesToVouch(id), + }, + }); + }, + + userProfileGet: function (req, res) { + const id = req.param("id"); + const requestor = req.param("requestor"); + const user = db.getUser(id); + const data = {}; + + data.id = id; + data.sponsored = db.isSponsored(id); + data.verifications = db.userVerifications(id); + data.recoveryConnections = db.getRecoveryConnections(id); + const connections = db.userConnections(id, "inbound"); + const memberships = db.userMemberships(id); + const isKnown = (c) => + ["just met", "already known", "recovery"].includes(c.level); + data.connectionsNum = connections.filter(isKnown).length; + data.groupsNum = memberships.length; + data.reports = connections + .filter((c) => c.level === "reported") + .map((c) => { + return { id: c.id, reason: c.reportReason }; + }); + data.createdAt = user.createdAt; + data.signingKeys = user.signingKeys; + data.requiredRecoveryNum = db.getRequiredRecoveryNum(id); + + if (requestor && usersColl.exists(requestor)) { + const requestorConnections = db.userConnections(requestor, "outbound"); + const requestorMemberships = db.userMemberships(requestor); + data.mutualConnections = _.intersection( + connections.filter(isKnown).map((c) => c.id), + requestorConnections.filter(isKnown).map((c) => c.id) + ); + data.mutualGroups = _.intersection( + memberships.map((m) => m.id), + requestorMemberships.map((m) => m.id) + ); + const conn = requestorConnections.find((c) => c.id === id); + if (conn) { + data.connectedAt = conn.timestamp; + data.level = conn.level; + } + } + res.send({ data }); + }, + + verificationPublicGet: function (req, res) { + const appKey = req.param("app"); + const app = db.getApp(appKey); + const roundedTimestamp = req.param("roundedTimestamp"); + const verification = req.param("verification"); + + if (!app.verifications.includes(verification)) { + throw new errors.UnacceptableVerification(verification, appKey); + } + + const vel = app.verificationExpirationLength; + const serverRoundedTimestamp = vel ? parseInt(Date.now() / vel) * vel : 0; + if (serverRoundedTimestamp !== roundedTimestamp) { + throw new errors.InvalidRoundedTimestampError( + serverRoundedTimestamp, + roundedTimestamp + ); + } + + const info = stringify({ app: appKey, roundedTimestamp, verification }); + const server = new WISchnorrServer(); + const params = server.GenerateWISchnorrParams(info); + const p = params.private; + cachedParamsColl.insert({ + public: stringify(params.public), + private: { u: p.u.toString(), s: p.s.toString(), d: p.d.toString() }, + app: appKey, + roundedTimestamp, + verification, + creationDate: parseInt(Date.now() / 1000), + }); + res.send({ + data: { public: params.public }, + }); + }, + + verificationSigGet: function (req, res) { + const id = req.param("id"); + const sig = req.param("sig"); + const e = req.param("e"); + const pub = req.param("public"); + + // to enable clients that requested the signed verification using the same public before + // but failed in receiving the response + let sv = signedVerificationsColl.firstExample({ publicHash: hash(pub) }); + if (sv) { + res.send({ + data: { + response: sv.response, + }, + }); + return; + } + + const params = db.getCachedParams(pub); + const msg = stringify({ id, public: JSON.parse(pub) }); + operations.verifyUserSig(msg, id, sig); + + + const verified = db.isVerifiedFor(id, params.verification) + if (!verified) { + throw new errors.NotVerifiedError(params.app, params.verification); + } + + const conf = module.context.configuration; + if (!(conf.wISchnorrPassword || conf.seed)) { + throw new errors.WISchnorrPasswordNotSetError(); + } + + const server = new WISchnorrServer(); + server.GenerateSchnorrKeypair(conf.wISchnorrPassword || conf.seed); + + const q = { + id, + roundedTimestamp: params.roundedTimestamp, + app: params.app, + verification: params.verification, + }; + sv = signedVerificationsColl.firstExample(q); + if (sv) { + throw new errors.DuplicateSigRequestError(); + } + + let priv = params.private; + priv = { + u: new BigInteger(priv.u), + s: new BigInteger(priv.s), + d: new BigInteger(priv.d), + }; + const response = server.GenerateWISchnorrServerResponse(priv, e); + // using hash of pub to reduce storage size + q["publicHash"] = hash(pub); + q["response"] = response; + signedVerificationsColl.insert(q); + res.send({ + data: { + response, + }, + }); + }, + + verificationAppUserIdPost: function (req, res) { + const app = req.param("app"); + const appUserId = req.param("appUserId"); + const { sig, verification, roundedTimestamp, uid } = req.body; + const client = new WISchnorrClient(db.getState().wISchnorrPublic); + const info = { app, verification, roundedTimestamp }; + const result = client.VerifyWISchnorrBlindSignature( + sig, + stringify(info), + uid + ); + if (!result) { + throw new errors.InvalidSignatureError(); + } + db.insertAppUserIdVerification( + app, + uid, + appUserId, + verification, + roundedTimestamp + ); + }, + + verificationsGet: function (req, res) { + const appKey = req.param("app"); + const signed = req.param("signed"); + let timestamp = req.param("timestamp"); + const includeHash = req.param("includeHash"); + const app = db.getApp(appKey); + const pseudoVerification = req.param("pseudoVerification"); + let appUserId = req.param("appUserId"); + if (app.idsAsHex) { + appUserId = appUserId.toLowerCase(); + } + const appUserIdExists = appIdsColl.firstExample({ + app: appKey, + appId: appUserId, + }); + const development = module.context.configuration.development; + if (!(development && pseudoVerification) && !appUserIdExists) { + throw new errors.AppUserIdNotFoundError(appUserId); + } + + const vel = app.verificationExpirationLength; + const roundedTimestamp = vel ? parseInt(Date.now() / vel) * vel : 0; + + if (timestamp == "seconds") { + timestamp = vel ? roundedTimestamp / 1000 : parseInt(Date.now() / 1000); + } else if (timestamp == "milliseconds") { + timestamp = vel ? roundedTimestamp : Date.now(); + } else { + timestamp = undefined; + } + + const results = []; + for (let verification of app.verifications) { + const verificationHash = crypto.sha256(verification); + let doc; + if (development && pseudoVerification) { + doc = { app: appKey, appId: appUserId, verification, roundedTimestamp }; + } else { + doc = appIdsColl.firstExample({ + app: appKey, + appId: appUserId, + verification, + roundedTimestamp, + }); + } + const unique = doc ? true : false; + const result = { + unique, + app: appKey, + appUserId, + verification, + sig: "", + timestamp, + }; + if (includeHash) { + result["verificationHash"] = verificationHash; + } + if (!doc) { + results.push(result); + continue; + } + + // sign and return the verification + let sig, publicKey; + if (signed == "nacl") { + const naclKeyPair = getNaclKeyPair(); + if (!naclKeyPair.privateKey) { + throw new errors.NaclKeyNotSetError(); + } + + let message = appKey + "," + appUserId; + if (includeHash) { + message = message + "," + verificationHash; + } + if (timestamp) { + message = message + "," + timestamp; + } + publicKey = naclKeyPair.publicKey; + sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), naclKeyPair.privateKey) + ) + ); + } else if (signed == "eth") { + const ethKeyPair = getEthKeyPair(); + if (!ethKeyPair.privateKey) { + throw new errors.EthKeyNotSetError(); + } + + let message, h; + if (app.idsAsHex) { + message = pad32(appKey) + addressToBytes32(appUserId); + } else { + if (appUserId.length > 32) { + throw new errors.UnsingableAppUserIdError(appUserId); + } + message = pad32(appKey) + pad32(appUserId); + } + message = Buffer.from(message, "binary").toString("hex"); + if (includeHash) { + message += verificationHash; + } + if (timestamp) { + const t = timestamp.toString(16); + message += "0".repeat(64 - t.length) + t; + } + h = new Uint8Array( + createKeccakHash("keccak256").update(message, "hex").digest() + ); + publicKey = ethKeyPair.publicKey; + const _sig = secp256k1.ecdsaSign(h, ethKeyPair.privateKey); + sig = { + r: Buffer.from(Object.values(_sig.signature.slice(0, 32))).toString( + "hex" + ), + s: Buffer.from(Object.values(_sig.signature.slice(32, 64))).toString( + "hex" + ), + v: _sig.recid + 27, + }; + } + + result["sig"] = sig; + result["publicKey"] = publicKey; + results.push(result); + } + res.send({ data: results }); + }, + + allVerificationsGet: function (req, res) { + const appKey = req.param("app"); + const countOnly = req.param("countOnly"); + const period = req.param("period"); + const data = db.getAppUserIds(appKey, period, countOnly); + res.send({ + data, + }); + }, + + appGet: function (req, res) { + const appKey = req.param("app"); + let app = db.getApp(appKey); + res.send({ + data: db.appToDic(app), + }); + }, + + allAppsGet: function (req, res) { + const apps = db.getApps().map((app) => db.appToDic(app)); + apps.sort((app1, app2) => { + const used1 = app1.assignedSponsorships - app1.unusedSponsorships; + const unused1 = app1.unusedSponsorships; + const used2 = app2.assignedSponsorships - app2.unusedSponsorships; + const unused2 = app2.unusedSponsorships; + return unused2 * used2 - unused1 * used1; + }); + res.send({ + data: { + apps, + }, + }); + }, + + stateGet: function (req, res) { + res.send({ + data: db.getState(), + }); + }, + + groupGet: function (req, res) { + const id = req.param("id"); + const group = db.getGroup(id); + res.send({ + data: { + id, + members: db.groupMembers(id), + invites: db.groupInvites(id), + admins: group.admins, + seed: group.seed || false, + region: group.region, + type: group.type || "general", + url: group.url, + info: group.info, + timestamp: group.timestamp, + }, + }); + }, + + sponsorshipGet: function (req, res) { + let appUserId = req.param("appUserId"); + if (db.isEthereumAddress(appUserId)) { + appUserId = appUserId.toLowerCase(); + } + const sponsorship = db.getSponsorship(appUserId); + res.send({ + data: { + app: sponsorship._to.replace("apps/", ""), + appHasAuthorized: sponsorship.appHasAuthorized, + spendRequested: sponsorship.spendRequested, + timestamp: sponsorship.timestamp, + }, + }); + }, + + peersGet: function (req, res) { + const conf = module.context.configuration; + res.send({ + peers: conf.peers ? conf.peers.split(",") : [], + }); + }, +}; + +router + .post("/operations", handlers.operationsPost) + .body(schemas.operation) + .summary("Add an operation to be applied after consensus") + .description("Add an operation be applied after consensus.") + .response(schemas.operationPostResponse) + .error(400, "Failed to add the operation") + .error(403, "Bad signature") + .error(429, "Too Many Requests"); + +router + .get("/users/:id/memberships", handlers.userMembershipsGet) + .pathParam( + "id", + joi.string().required().description("the brightid of the user") + ) + .summary("Gets memberships of the user") + .response(schemas.userMembershipsGetResponse); + +router + .get("/users/:id/invites", handlers.userInvitesGet) + .pathParam( + "id", + joi.string().required().description("the brightid of the user") + ) + .summary("Gets invites of the user") + .response(schemas.userInvitesGetResponse); + +router + .get("/users/:id/verifications", handlers.userVerificationsGet) + .pathParam( + "id", + joi.string().required().description("the brightid of the user") + ) + .summary("Gets verifications of the user") + .response(schemas.userVerificationsGetResponse); + +router + .get("/users/:id/profile", handlers.userProfileGet) + .pathParam( + "id", + joi + .string() + .required() + .description("the brightid of the user that info requested about") + ) + .summary("Gets profile information of a user") + .response(schemas.userProfileGetResponse) + .error(404, "User not found"); + +router + .get("/users/:id/profile/:requestor", handlers.userProfileGet) + .pathParam( + "id", + joi + .string() + .required() + .description("the brightid of the user that info requested about") + ) + .pathParam( + "requestor", + joi + .string() + .required() + .description("the brightid of the user that requested info") + ) + .summary("Gets profile information of a user") + .description( + "Gets profile information of a user, including requestor's mutal connections/groups info" + ) + .response(schemas.userProfileGetResponse) + .error(404, "User not found"); + +router + .get("/users/:id/connections/:direction", handlers.userConnectionsGet) + .pathParam( + "id", + joi.string().required().description("the brightid of the user") + ) + .pathParam( + "direction", + joi + .string() + .required() + .valid("inbound", "outbound") + .description("the direction of the connection") + ) + .summary("Gets inbound or outbound connections of a user") + .description("Gets user's connections with levels and timestamps") + .response(schemas.userConnectionsGetResponse); + +router + .get("/users/:id/familiesToVouch", handlers.userFamiliesToVouchGet) + .pathParam( + "id", + joi.string().required().description("the brightid of the user") + ) + .summary("Gets family groups which the user can vouch for") + .response(schemas.userFamiliesToVouchGetResponse); + +router + .get("/operations/:hash", handlers.operationGet) + .pathParam( + "hash", + joi.string().required().description("sha256 hash of the operation message") + ) + .summary("Gets state and result of an operation") + .response(schemas.operationGetResponse) + .error(404, "Operation not found"); + +router + .get("/verifications/blinded/public", handlers.verificationPublicGet) + .queryParam("app", joi.string().required().description("the key of the app")) + .queryParam( + "roundedTimestamp", + joi + .number() + .integer() + .required() + .description( + "timestamp that is rounded to app's required precision or zero" + ) + ) + .queryParam( + "verification", + joi.string().required().description("custom verification expression") + ) + .summary("Gets public part of WI-Schnorr params") + .description( + "Gets public part of WI-Schnorr params using deterministic json representation of {app, roundedTimestamp, verification} as info" + ) + .response(schemas.verificationPublicGetResponse) + .error(404, "app not found") + .error(403, "invalid rounded timestamp"); + +router + .get("/verifications/blinded/sig/:id", handlers.verificationSigGet) + .pathParam( + "id", + joi + .string() + .required() + .description("the brightid of the user requesting the verification") + ) + .queryParam( + "public", + joi.string().required().description("public part of WI-Schnorr params") + ) + .queryParam( + "sig", + joi + .string() + .description( + "deterministic json representation of {id, public} signed by the user represented by id" + ) + ) + .queryParam( + "e", + joi + .string() + .required() + .description( + "the e part of WI-Schnorr challenge generated by client using public provided by node" + ) + ) + .summary("Gets WI-Schnorr server response") + .description( + "Gets WI-Schnorr server response that will be used by client to generate final signature to be shared with the app" + ) + .response(schemas.verificationSigGetResponse) + .error(404, "app not found") + .error(403, "invalid rounded timestamp"); + +router + .post("/verifications/:app/:appUserId", handlers.verificationAppUserIdPost) + .pathParam( + "app", + joi.string().required().description("the app that user is verified for") + ) + .pathParam( + "appUserId", + joi.string().required().description("the id of the user within the app") + ) + .body(schemas.verificationAppUserIdPostBody) + .summary("Posts an unblinded signature") + .description( + "Clients use this endpoint to add unblinded signature for an appUserId to the node to be queried by apps" + ) + .response(null); + +router + .get("/verifications/:app/:appUserId/", handlers.verificationsGet) + .pathParam( + "app", + joi.string().required().description("the app that user is verified for") + ) + .pathParam( + "appUserId", + joi.string().required().description("the id of user within the app") + ) + .queryParam( + "signed", + joi + .string() + .description( + "the value will be eth or nacl to indicate the type of signature returned" + ) + ) + .queryParam( + "timestamp", + joi + .string() + .description( + 'request a timestamp of the specified format to be added to the response. Accepted values: "seconds", "milliseconds"' + ) + ) + .queryParam( + "includeHash", + joi + .boolean() + .default(true) + .description("false if the requester doesn't want the hash included") + ) + .queryParam( + "pseudoVerification", + joi + .boolean() + .default(false) + .description("true if the requester wants a pseudo verification") + ) + .summary("Gets a signed verification") + .description( + "Apps use this endpoint to query all signed verifications for an appUserId from the node" + ) + .response(schemas.verificationsGetResponse) + .error(404, "appUserId not found"); + +router + .get("/verifications/:app", handlers.allVerificationsGet) + .pathParam( + "app", + joi + .string() + .required() + .description("the app for which the user is verified") + ) + .queryParam( + "period", + joi + .string() + .default("all") + .valid("previous", "current", "all") + .description("defines expiration periods") + ) + .queryParam( + "countOnly", + joi + .boolean() + .default(false) + .description( + "true if the requester doesn't want the array of the app generated ids included" + ) + ) + .summary("Gets array of all of app generated ids verified for the app") + .description( + "Gets array of all of app generated ids that are sponsored and verified for using the app" + ) + .response(schemas.allVerificationsGetResponse) + .error(404, "app not found"); + +router + .get("/apps/:app", handlers.appGet) + .pathParam( + "app", + joi.string().required().description("Unique name of the app") + ) + .summary("Gets information about an app") + .response(schemas.appGetResponse) + .error(404, "app not found"); + +router + .get("/apps", handlers.allAppsGet) + .summary("Gets all apps") + .response(schemas.allAppsGetResponse); + +router + .get("/state", handlers.stateGet) + .summary("Gets state of this node") + .response(schemas.stateGetResponse); + +router + .get("/groups/:id", handlers.groupGet) + .pathParam("id", joi.string().required().description("the id of the group")) + .summary("Gets information about a group") + .description( + "Gets a group's admins, info, region, seed, type, url, timestamp, members and invited list." + ) + .response(schemas.groupGetResponse) + .error(404, "Group not found"); + +router + .get("/sponsorships/:appUserId", handlers.sponsorshipGet) + .pathParam( + "appUserId", + joi + .string() + .required() + .description("the app generated id that info is requested about") + ) + .summary("Gets sponsorship information of an app generated id") + .response(schemas.sponsorshipGetResponse) + .error(404, "App generated id not found"); + +router + .get("/peers", handlers.peersGet) + .summary("Gets other nodes this node trusts") + .response(schemas.peersGetResponse); + +module.context.use(function (req, res, next) { + try { + next(); + } catch (e) { + if (e.cause && e.cause.isJoi) { + e.code = 400; + if ( + req._raw.url.includes("operations") && + e.cause.details && + e.cause.details.length > 0 + ) { + let msg1 = ""; + const msg2 = "invalid operation name"; + e.cause.details.forEach((d) => { + if (!d.message.includes('"name" must be one of')) { + msg1 += `${d.message}, `; + } + }); + e.message = msg1 || msg2; + } + } + if (!(e instanceof errors.NotFoundError)) { + console.group("Error returned"); + console.log("url:", req._raw.requestType, req._raw.url); + console.log("error:", e); + console.log("body:", req.body); + console.groupEnd(); + } + let options = undefined; + if (e instanceof ArangoError) { + options = { extra: { arangoErrorNum: e.errorNum } }; + e.errorNum = errors.ARANGO_ERROR; + } + res.throw(e.code || 500, e, options); + } +}); + +module.exports = { + handlers, +}; diff --git a/web_services/foxx/brightid6/initdb.js b/web_services/foxx/brightid6/initdb.js new file mode 100755 index 00000000..a39b777c --- /dev/null +++ b/web_services/foxx/brightid6/initdb.js @@ -0,0 +1,219 @@ +const arango = require("@arangodb").db; +const db = require("./db"); +const { query } = require("@arangodb"); + +const collections = { + connections: "edge", + connectionsHistory: "edge", + groups: "document", + usersInGroups: "edge", + users: "document", + // this collection should be dropped when v5 drops + contexts: "document", + apps: "document", + sponsorships: "edge", + operations: "document", + operationsHashes: "document", + invitations: "edge", + variables: "document", + verifications: "document", + // this collection should be dropped when v5 drops + testblocks: "document", + cachedParams: "document", + signedVerifications: "document", + appIds: "document", + operationCounters: "document", +}; + +// deprecated collections should be added to this array after releasing +// second update to allow 2 last released versions work together +const deprecated = ["removed", "newGroups", "usersInNewGroups"]; + +const indexes = [ + { collection: "verifications", fields: ["user"], type: "persistent" }, + { collection: "verifications", fields: ["name"], type: "persistent" }, + { collection: "verifications", fields: ["block"], type: "persistent" }, + { collection: "verifications", fields: ["expression"], type: "persistent" }, + { collection: "verifications", fields: ["rank"], type: "persistent" }, + { + collection: "sponsorships", + fields: ["expireDate"], + type: "ttl", + expireAfter: 0, + }, + { collection: "sponsorships", fields: ["contextId"], type: "persistent" }, + { collection: "sponsorships", fields: ["appId"], type: "persistent" }, + { collection: "sponsorships", fields: ["expireDate"], type: "persistent" }, + { + collection: "sponsorships", + fields: ["appHasAuthorized"], + type: "persistent", + }, + { + collection: "sponsorships", + fields: ["spendRequested"], + type: "persistent", + }, + { collection: "connections", fields: ["level"], type: "persistent" }, + { collection: "connections", fields: ["timestamp"], type: "persistent" }, + { + collection: "connectionsHistory", + fields: ["timestamp"], + type: "persistent", + }, + { collection: "groups", fields: ["seed"], type: "persistent" }, + { collection: "groups", fields: ["type"], type: "persistent" }, + { collection: "groups", fields: ["head"], type: "persistent" }, + { collection: "operations", fields: ["state"], type: "persistent" }, + { collection: "operations", fields: ["name"], type: "persistent" }, + { collection: "operations", fields: ["timestamp"], type: "persistent" }, + { collection: "operations", fields: ["contextId"], type: "persistent" }, + { collection: "operations", fields: ["appUserId"], type: "persistent" }, + { + collection: "cachedParams", + fields: ["creationDate"], + type: "ttl", + expireAfter: 600, + }, + { collection: "appIds", fields: ["uid"], type: "persistent" }, + { collection: "appIds", fields: ["app", "appId"], type: "persistent" }, + { + collection: "operationCounters", + fields: ["expireDate"], + type: "ttl", + expireAfter: 0, + }, + { + collection: "signedVerifications", + fields: ["id", "roundedTimestamp", "app", "verification"], + type: "persistent", + unique: true, + }, +]; + +const variables = [ + { _key: "LAST_DB_UPGRADE_V6", value: -1 }, + { _key: "VERIFICATIONS_HASHES", hashes: "{}" }, + { _key: "VERIFICATION_BLOCK", value: 0 }, + // 2021/02/09 as starting point for applying new seed connected + { _key: "PREV_SNAPSHOT_TIME", value: 1612900000 }, +]; + +const variablesColl = arango._collection("variables"); + +function createCollections() { + console.log("creating collections if they do not exist ..."); + for (let collection in collections) { + const coll = arango._collection(collection); + if (coll) { + console.log(`${collection} exists`); + } else { + const type = collections[collection]; + arango._create(collection, {}, type); + console.log(`${collection} created with type ${type}`); + } + } +} + +function createIndexes() { + console.log("creating indexes ..."); + for (let index of indexes) { + const coll = arango._collection(index.collection); + console.log(`${index.fields} indexed in ${index.collection} collection`); + delete index.collection; + coll.ensureIndex(index); + } +} + +function removeDeprecatedCollections() { + console.log("removing deprecated collections"); + for (let collection of deprecated) { + const coll = arango._collection(collection); + if (coll) { + arango._drop(collection); + console.log(`${collection} dropped`); + } else { + console.log(`${collection} dropped before`); + } + } +} + +function initializeVariables() { + console.log("initialize variables ..."); + for (let variable of variables) { + if (!variablesColl.exists(variable._key)) { + variablesColl.insert(variable); + } + } +} + +function v6_8() { + const connectionsHistoryColl = arango._collection("connectionsHistory"); + connectionsHistoryColl + .all() + .toArray() + .forEach((conn) => { + if (conn._from == conn._to) { + connectionsHistoryColl.remove(conn); + } + }); + + const connectionsColl = arango._collection("connections"); + connectionsColl + .all() + .toArray() + .forEach((conn) => { + if (conn._from == conn._to) { + connectionsColl.remove(conn); + } + }); +} + +function v6_16() { + const sponsorshipsColl = arango._collection("sponsorships"); + const appsColl = arango._collection("apps"); + + console.log("adding 'usedSponsorships' to apps"); + appsColl + .all() + .toArray() + .forEach((app) => { + const usedSponsorships = query` + FOR s in ${sponsorshipsColl} + FILTER s._to == ${app._id} + AND s.expireDate == null + COLLECT WITH COUNT INTO length + RETURN length + `.toArray()[0]; + + appsColl.update(app, { usedSponsorships }); + }); + + console.log("removing 'wsProvider' from apps"); + query` + FOR app IN apps + REPLACE UNSET(app, 'wsProvider') IN apps`; +} + +const upgrades = ["v6_8", "v6_16"]; + +function initdb() { + createCollections(); + createIndexes(); + removeDeprecatedCollections(); + initializeVariables(); + let index; + if (variablesColl.exists("LAST_DB_UPGRADE_V6")) { + upgrade = variablesColl.document("LAST_DB_UPGRADE_V6").value; + index = upgrades.indexOf(upgrade) + 1; + } else { + index = 0; + } + while (upgrades[index]) { + eval(upgrades[index])(); + variablesColl.update("LAST_DB_UPGRADE_V6", { value: upgrades[index] }); + index += 1; + } +} + +initdb(); diff --git a/web_services/foxx/brightid/manifest.json b/web_services/foxx/brightid6/manifest.json similarity index 100% rename from web_services/foxx/brightid/manifest.json rename to web_services/foxx/brightid6/manifest.json diff --git a/web_services/foxx/brightid6/manifest_apply.json b/web_services/foxx/brightid6/manifest_apply.json new file mode 100644 index 00000000..12ba94b4 --- /dev/null +++ b/web_services/foxx/brightid6/manifest_apply.json @@ -0,0 +1,9 @@ +{ + "main": "apply.js", + "name": "apply", + "description": "Allows BrightID consensus module to apply operations to the database.", + "version": "6.17.2", + "scripts": { + "setup": "initdb.js" + } +} diff --git a/web_services/foxx/brightid6/operations.js b/web_services/foxx/brightid6/operations.js new file mode 100755 index 00000000..97816c39 --- /dev/null +++ b/web_services/foxx/brightid6/operations.js @@ -0,0 +1,333 @@ +const stringify = require("fast-json-stable-stringify"); +const db = require("./db"); +const { db: arango, query } = require("@arangodb"); +const nacl = require("tweetnacl"); + +const { + strToUint8Array, + b64ToUint8Array, + urlSafeB64ToB64, + hash, +} = require("./encoding"); +const errors = require("./errors"); + +const usersColl = arango._collection("users"); +const operationCountersColl = arango._collection("operationCounters"); +const sponsorshipsColl = arango._collection("sponsorships"); + +const TIME_FUDGE = 60 * 60 * 1000; // timestamp can be this far in the future (milliseconds) to accommodate client/server clock differences + +const verifyUserSig = function (message, id, sig) { + // When "Connect" is called by a user that is not created yet + // signingKey can be calculated from user's brightid + let signingKeys = usersColl.exists(id) + ? usersColl.document(id).signingKeys + : [urlSafeB64ToB64(id)]; + for (let signingKey of signingKeys) { + if ( + nacl.sign.detached.verify( + strToUint8Array(message), + b64ToUint8Array(sig), + b64ToUint8Array(signingKey) + ) + ) { + return signingKey; + } + } + throw new errors.InvalidSignatureError(); +}; + +const verifyAppSig = function (message, app, sig) { + app = db.getApp(app); + if ( + !nacl.sign.detached.verify( + strToUint8Array(message), + b64ToUint8Array(sig), + b64ToUint8Array(app.sponsorPublicKey) + ) + ) { + throw new errors.InvalidSignatureError(); + } +}; + +const senderAttrs = { + Connect: ["id1"], + "Add Group": ["id"], + "Remove Group": ["id"], + "Add Membership": ["id"], + "Remove Membership": ["id"], + "Social Recovery": ["id"], + Sponsor: ["app", "id"], //! TODO: deprecated (we are supporting old clients for now) app is not required anymore + "Spend Sponsorship": ["app"], //! TODO: deprecated (we are supporting old clients for now) + Invite: ["inviter"], + Dismiss: ["dismisser"], + "Add Admin": ["id"], + "Add Signing Key": ["id"], + "Remove Signing Key": ["id"], + "Remove All Signing Keys": ["id"], + "Update Group": ["id"], + "Vouch Family": ["id"], + "Set Family Head": ["id"], + "Convert To Family": ["id"], + "Set Required Recovery Num": ["id"], +}; + +function checkLimits(op, timeWindow, limit) { + let expireDate; + const now = Date.now(); + const senders = senderAttrs[op.name].map((attr) => op[attr]); + for (let sender of senders) { + // these condition structure is applying: + // 1) a bucket for a verified user + // 2) a bucket for children of a verified user + // 3) a bucket for all non-verified users without parent + // 4) a bucket for an app + // where parent is the first verified user that make connection with the user + + //! TODO: deprecated (we are supporting old clients for now) + if (op["name"] == "Spend Sponsorship") { + const app = db.getApp(op.app); + const appUserId = app.idsAsHex ? op.appUserId.toLowerCase() : op.appUserId; + const sponsorship = sponsorshipsColl.firstExample({ + appId: appUserId, + }); + if (!sponsorship) { + sender = "shared_apps"; + } else if (sponsorship.spendRequested) { + throw new errors.SpendRequestedBeforeError(); + } else if (!sponsorship.appHasAuthorized) { + sender = "shared_apps"; + } + } + + //! TODO: deprecated (we are supporting old clients for now) + if (!["Sponsor", "Spend Sponsorship"].includes(op["name"])) { + if (!usersColl.exists(sender)) { + // this happens when operation is "Connect" and sender does not exist + sender = "shared"; + } else { + const user = usersColl.document(sender); + const verifications = db + .userVerifications(user._key) + .map((v) => v.name); + verified = verifications && verifications.includes("BrightID"); + if (!verified && user.parent) { + // this happens when user is not verified but has a verified connection + sender = `shared_${user.parent}`; + } else if (!verified && !user.parent) { + // this happens when user is not verified and does not have a verified connection + sender = "shared"; + } + } + } + const cursor = operationCountersColl.firstExample({ _key: sender }); + let counter = cursor ? cursor.counter : 0; + expireDate = cursor + ? cursor.expireDate + : Math.ceil(now / 1000 + timeWindow / 1000); + counter += 1; + query` + UPSERT { _key: ${sender} } + INSERT { + _key: ${sender}, + counter: ${counter}, + expireDate: ${expireDate}, + } + UPDATE { counter: ${counter} } + IN operationCounters + `; + + if (counter <= limit) { + // if operation has multiple senders, this check will be passed + // even if one of the senders did not reach limit yet + return; + } + } + + throw new errors.TooManyOperationsError( + senders, + expireDate * 1000 - now, + timeWindow, + limit + ); +} + +const signerAndSigs = { + "Add Group": ["id", "sig"], + "Remove Group": ["id", "sig"], + "Add Membership": ["id", "sig"], + "Remove Membership": ["id", "sig"], + Invite: ["inviter", "sig"], + Dismiss: ["dismisser", "sig"], + "Add Admin": ["id", "sig"], + "Update Group": ["id", "sig"], + "Add Signing Key": ["id", "sig"], + "Remove Signing Key": ["id", "sig"], + "Remove All Signing Keys": ["id", "sig"], + "Vouch Family": ["id", "sig"], + "Set Family Head": ["id", "sig"], + "Convert To Family": ["id", "sig"], + "Set Required Recovery Num": ["id", "sig"], +}; + +function verify(op) { + if (op.v != 6) { + throw new errors.InvalidOperationVersionError(op.v); + } + if (op.timestamp > Date.now() + TIME_FUDGE) { + throw new errors.InvalidOperationTimestampError(op.timestamp); + } + + let message = getMessage(op); + if(op.name == "Sponsor" && op.id) { + verifyUserSig(message, op.id, op.sig); + } + //! TODO: deprecated (we are supporting old clients for now) + else if (op.name == "Sponsor" && op.appUserId) { + verifyAppSig(message, op.app, op.sig); + // prevent apps from sending duplicate sponsor requests + if (db.sponsorRequestedRecently(op)) { + throw new errors.SponsorRequestedRecently(); + } else if (db.isSponsoredByAppUserId(op)) { + throw new errors.SponsoredBeforeError(); + } + } else if (op.name == "Spend Sponsorship") { + // there is no sig on this operation + return; + } else if (op.name == "Social Recovery") { + const requiredRecoveryNum = db.getRequiredRecoveryNum(op.id); + const recoveryConnections = db.getRecoveryConnections(op.id); + const temp = new Set(); + for (let i = 1; i <= requiredRecoveryNum; i++) { + if (!(`id${i}` in op)) { + throw new errors.WrongNumberOfSignersError( + `id${i}`, + requiredRecoveryNum + ); + } + + if (temp.has(op[`id${i}`])) { + throw new errors.DuplicateSignersError(); + } + + const rc = recoveryConnections.find((c) => c.id == op[`id${i}`]); + if (!rc) { + throw new errors.NotRecoveryConnectionsError(); + } + + if (rc.activeAfter != 0) { + throw new errors.WaitForCooldownError(op[`id${i}`]); + } + + verifyUserSig(message, op[`id${i}`], op[`sig${i}`]); + temp.add(op[`id${i}`]); + } + } else if (op.name == "Connect") { + verifyUserSig(message, op.id1, op.sig1); + if (op.requestProof) { + verifyUserSig(op.id2 + "|" + op.timestamp, op.id2, op.requestProof); + } + } else { + const [signerAttr, sigAttr] = signerAndSigs[op.name]; + const signer = op[signerAttr]; + const sig = op[sigAttr]; + verifyUserSig(message, signer, sig); + } + + if (hash(message) != op.hash) { + throw new errors.InvalidOperationHashError(); + } +} + +function apply(op) { + if (op["name"] == "Remove All Signing Keys") { + // verifyUserSig returns the key that used to sign the op + // removeAllSigningKeys remove all keys except this one + const signingKey = verifyUserSig(getMessage(op), op.id, op.sig); + op.timestamp = op.blockTime; + return db.removeAllSigningKeys(op.id, signingKey, op.timestamp); + } + + // set the block time instead of user timestamp + op.timestamp = op.blockTime; + if (op["name"] == "Connect") { + return db.connect(op); + } else if (op["name"] == "Add Group") { + return db.createGroup(op.group, op.id, op.url, op.type, op.timestamp); + } else if (op["name"] == "Remove Group") { + return db.deleteGroup(op.group, op.id, op.timestamp); + } else if (op["name"] == "Add Membership") { + return db.addMembership(op.group, op.id, op.timestamp); + } else if (op["name"] == "Remove Membership") { + return db.deleteMembership(op.group, op.id, op.timestamp); + } else if (op["name"] == "Social Recovery") { + return db.setSigningKey(op.signingKey, op.id, op.timestamp); + //! TODO: deprecated (we are supporting old clients for now) + } else if (["Sponsor", "Spend Sponsorship"].includes(op["name"])) { + return db.sponsor(op); + } else if (op["name"] == "Invite") { + return db.invite(op.inviter, op.invitee, op.group, op.data, op.timestamp); + } else if (op["name"] == "Dismiss") { + return db.dismiss(op.dismisser, op.dismissee, op.group, op.timestamp); + } else if (op["name"] == "Add Admin") { + return db.addAdmin(op.id, op.admin, op.group, op.timestamp); + } else if (op["name"] == "Add Signing Key") { + return db.addSigningKey(op.id, op.signingKey, op.timestamp); + } else if (op["name"] == "Remove Signing Key") { + return db.removeSigningKey(op.id, op.signingKey, op.timestamp); + } else if (op["name"] == "Update Group") { + return db.updateGroup(op.id, op.group, op.url, op.timestamp); + } else if (op["name"] == "Vouch Family") { + return db.vouchFamily(op.id, op.group, op.timestamp); + } else if (op["name"] == "Set Family Head") { + return db.setFamilyHead(op.id, op.head, op.group, op.timestamp); + } else if (op["name"] == "Convert To Family") { + return db.convertToFamily(op.id, op.head, op.group, op.timestamp); + } else if (op["name"] == "Set Required Recovery Num") { + return db.setRequiredRecoveryNum( + op.id, + op.requiredRecoveryNum, + op.timestamp + ); + } else { + throw new errors.InvalidOperationNameError(op["name"]); + } +} + +function getMessage(op) { + const signedOp = {}; + for (let k in op) { + if ( + [ + "sig", + "sig1", + "sig2", + "sig3", + "sig4", + "sig5", + "hash", + "blockTime", + "n", + "e", + "unblinded", + ].includes(k) + ) { + continue; + } else if ( + op.name == "Social Recovery" && + ["id1", "id2", "id3", "id4", "id5"].includes(k) + ) { + continue; + } + signedOp[k] = op[k]; + } + return stringify(signedOp); +} + +module.exports = { + verify, + apply, + verifyUserSig, + checkLimits, + getMessage, +}; diff --git a/web_services/foxx/brightid6/package-lock.json b/web_services/foxx/brightid6/package-lock.json new file mode 100644 index 00000000..0eb8db36 --- /dev/null +++ b/web_services/foxx/brightid6/package-lock.json @@ -0,0 +1,200 @@ +{ + "name": "brightid-foxx", + "version": "6.18.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "brightid-foxx", + "version": "6.18.0", + "dependencies": { + "base64-js": "^1.3.0", + "crypto-js": "^3.1.9-1", + "expr-eval": "^2.0.2", + "fast-json-stable-stringify": "^2.1.0", + "js-sha256": "0.9.0", + "jsbn": "1.1.0", + "keccak": "^3.0.0", + "lodash": "^4.17.11", + "node-rsa": "1.0.8", + "secp256k1": "^4.0.0", + "tweetnacl": "^1.0.0" + } + }, + "node_modules/asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" + }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=" + }, + "node_modules/crypto-js": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.3.0.tgz", + "integrity": "sha512-DIT51nX0dCfKltpRiXV+/TVZq+Qq2NgF4644+K7Ttnla7zEzqc+kjJyiB96BHNyUTBxyjzRcZYpUdZa+QAqi6Q==" + }, + "node_modules/elliptic": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", + "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/expr-eval": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/expr-eval/-/expr-eval-2.0.2.tgz", + "integrity": "sha512-4EMSHGOPSwAfBiibw3ndnP0AvjDWLsMvGOvWEZ2F96IGk0bIVdjQisOHxReSkE13mHcfbuCiXw+G4y0zv6N8Eg==" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/js-sha256": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.9.0.tgz", + "integrity": "sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==" + }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha1-sBMHyym2GKHtJux56RH4A8TaAEA=" + }, + "node_modules/keccak": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/keccak/-/keccak-3.0.1.tgz", + "integrity": "sha512-epq90L9jlFWCW7+pQa6JOnKn2Xgl2mtI664seYR6MHskvI9agt7AnDqmAlp9TqU4/caMYbA08Hi5DMZAl5zdkA==", + "hasInstallScript": true, + "dependencies": { + "node-addon-api": "^2.0.0", + "node-gyp-build": "^4.2.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=" + }, + "node_modules/node-addon-api": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.2.tgz", + "integrity": "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==" + }, + "node_modules/node-gyp-build": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.2.3.tgz", + "integrity": "sha512-MN6ZpzmfNCRM+3t57PTJHgHyw/h4OWnZ6mR8P5j/uZtqQr46RRuDE/P+g3n0YR/AiYXeWixZZzaip77gdICfRg==", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-rsa": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-1.0.8.tgz", + "integrity": "sha512-q8knkMHEqViIX/fshOltCHTtlt4Nw5wpBpu0//LB1tkxqYZB/001dYMwbPvTPiENwKvPqVDkhxK6J4fV09oa7w==", + "dependencies": { + "asn1": "^0.2.4" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/secp256k1": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-4.0.2.tgz", + "integrity": "sha512-UDar4sKvWAksIlfX3xIaQReADn+WFnHvbVujpcbr+9Sf/69odMwy2MUsz5CKLQgX9nsIyrjuxL2imVyoNHa3fg==", + "hasInstallScript": true, + "dependencies": { + "elliptic": "^6.5.2", + "node-addon-api": "^2.0.0", + "node-gyp-build": "^4.2.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" + } + } +} diff --git a/web_services/foxx/brightid6/package.json b/web_services/foxx/brightid6/package.json new file mode 100755 index 00000000..07155727 --- /dev/null +++ b/web_services/foxx/brightid6/package.json @@ -0,0 +1,18 @@ +{ + "name": "brightid-foxx", + "description": "Foxx service for managing BrightID connections", + "version": "6.18.0", + "dependencies": { + "base64-js": "^1.3.0", + "crypto-js": "^3.1.9-1", + "expr-eval": "^2.0.2", + "fast-json-stable-stringify": "^2.1.0", + "keccak": "^3.0.0", + "lodash": "^4.17.11", + "secp256k1": "^4.0.0", + "tweetnacl": "^1.0.0", + "js-sha256": "0.9.0", + "jsbn": "1.1.0", + "node-rsa": "1.0.8" + } +} diff --git a/web_services/foxx/brightid6/rsablind.js b/web_services/foxx/brightid6/rsablind.js new file mode 100755 index 00000000..20236412 --- /dev/null +++ b/web_services/foxx/brightid6/rsablind.js @@ -0,0 +1,151 @@ +"use strict"; + +var crypto = require("@arangodb/crypto"); +var BigInteger = require("jsbn").BigInteger; +var sha256 = require("js-sha256"); +var NodeRSA = require("node-rsa"); + +function keyGeneration(params) { + var key = new NodeRSA( + params || { + b: 2048, + } + ); + return key; +} + +function keyProperties(key) { + return { + E: new BigInteger(key.keyPair.e.toString()), + N: key.keyPair.n, + D: key.keyPair.d, + }; +} + +function messageToHash(message) { + var messageHash = sha256(message); + return messageHash; +} + +function messageToHashInt(message) { + var messageHash = messageToHash(message); + var messageBig = new BigInteger(messageHash, 16); + return messageBig; +} + +function blind(_ref) { + var message = _ref.message, + key = _ref.key, + N = _ref.N, + E = _ref.E; + var messageHash = messageToHashInt(message); + N = key ? key.keyPair.n : new BigInteger(N.toString()); + E = key + ? new BigInteger(key.keyPair.e.toString()) + : new BigInteger(E.toString()); + var bigOne = new BigInteger("1"); + var gcd; + var r; + + do { + r = new BigInteger(crypto.genRandomBytes(64)).mod(N); + gcd = r.gcd(N); + } while ( + !gcd.equals(bigOne) || + r.compareTo(N) >= 0 || + r.compareTo(bigOne) <= 0 + ); + + var blinded = messageHash.multiply(r.modPow(E, N)).mod(N); + return { + blinded: blinded, + r: r, + }; +} + +function sign(_ref2) { + var blinded = _ref2.blinded, + key = _ref2.key; + + var _keyProperties = keyProperties(key), + N = _keyProperties.N, + D = _keyProperties.D; + + blinded = new BigInteger(blinded.toString()); + var signed = blinded.modPow(D, N); + return signed; +} + +function unblind(_ref3) { + var signed = _ref3.signed, + key = _ref3.key, + r = _ref3.r, + N = _ref3.N; + r = new BigInteger(r.toString()); + N = key ? key.keyPair.n : new BigInteger(N.toString()); + signed = new BigInteger(signed.toString()); + var unblinded = signed.multiply(r.modInverse(N)).mod(N); + return unblinded; +} + +function verify(_ref4) { + var unblinded = _ref4.unblinded, + key = _ref4.key, + message = _ref4.message, + E = _ref4.E, + N = _ref4.N; + unblinded = new BigInteger(unblinded.toString()); + var messageHash = messageToHashInt(message); + N = key ? key.keyPair.n : new BigInteger(N.toString()); + E = key + ? new BigInteger(key.keyPair.e.toString()) + : new BigInteger(E.toString()); + var originalMsg = unblinded.modPow(E, N); + var result = messageHash.equals(originalMsg); + return result; +} + +function verify2(_ref5) { + var unblinded = _ref5.unblinded, + key = _ref5.key, + message = _ref5.message; + unblinded = new BigInteger(unblinded.toString()); + var messageHash = messageToHashInt(message); + + var _keyProperties2 = keyProperties(key), + D = _keyProperties2.D, + N = _keyProperties2.N; + + var msgSig = messageHash.modPow(D, N); + var result = unblinded.equals(msgSig); + return result; +} + +function verifyBlinding(_ref6) { + var blinded = _ref6.blinded, + r = _ref6.r, + unblinded = _ref6.unblinded, + key = _ref6.key, + E = _ref6.E, + N = _ref6.N; + var messageHash = messageToHashInt(unblinded); + r = new BigInteger(r.toString()); + N = key ? key.keyPair.n : new BigInteger(N.toString()); + E = key + ? new BigInteger(key.keyPair.e.toString()) + : new BigInteger(E.toString()); + var blindedHere = messageHash.multiply(r.modPow(E, N)).mod(N); + var result = blindedHere.equals(blinded); + return result; +} + +module.exports = { + keyGeneration: keyGeneration, + messageToHash: messageToHash, + blind: blind, + sign: sign, + unblind: unblind, + verify: verify, + verify2: verify2, + verifyBlinding: verifyBlinding, +}; diff --git a/web_services/foxx/brightid6/schemas.js b/web_services/foxx/brightid6/schemas.js new file mode 100755 index 00000000..0bc61dad --- /dev/null +++ b/web_services/foxx/brightid6/schemas.js @@ -0,0 +1,983 @@ +const joi = require("joi"); + +// lowest-level schemas +var schemas = { + timestamp: joi.number().integer(), +}; + +const operations = { + Connect: { + id1: joi + .string() + .required() + .description("brightid of the user making the directed connection"), + id2: joi + .string() + .required() + .description("brightid of the target of the directed connection"), + sig1: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id1" + ), + level: joi + .string() + .valid("reported", "suspicious", "just met", "already known", "recovery") + .required() + .description("level of confidence"), + reportReason: joi + .string() + .valid("spammer", "fake", "duplicate", "deceased", "replaced", "other") + .description( + "for reported level, the reason for reporting the user specificed by id2" + ), + replacedWith: joi + .string() + .description( + "for reported as replaced, the new brightid of the replaced account" + ), + requestProof: joi + .string() + .description( + "brightid + | + timestamp signed by the reported user to prove that he requested the connection" + ), + }, + "Add Group": { + group: joi.string().required().description("the unique id of the group"), + id: joi.string().required().description("brightid of the group founder"), + url: joi + .string() + .required() + .description( + "the url that group data (profile image and name) encrypted by group AES key can be fetched from" + ), + type: joi + .string() + .valid("general", "family") + .required() + .description("type of the group"), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the founder of the group represented by id" + ), + }, + "Remove Group": { + id: joi + .string() + .required() + .description("brightid of the group admin who want to remove the group"), + group: joi.string().required().description("the unique id of the group"), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id1" + ), + }, + "Add Membership": { + id: joi + .string() + .required() + .description("brightid of the user wants to join the group"), + group: joi + .string() + .required() + .description( + "the unique id of the group that the user represented by id wants to join" + ), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id" + ), + }, + "Remove Membership": { + id: joi + .string() + .required() + .description("brightid of the user wants to leave the group"), + group: joi + .string() + .required() + .description( + "the unique id of the group that the user represented by id wants to leave" + ), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id" + ), + }, + "Social Recovery": { + id: joi + .string() + .required() + .description( + "brightid of the user who is resetting signingKeys by social recovery" + ), + signingKey: joi + .string() + .required() + .description( + "the public key of the new key pair that user will use to sign operations with" + ), + id1: joi + .string() + .required() + .description( + "brightid of a recovery connection of the user represented by id" + ), + id2: joi + .string() + .required() + .description( + "brightid of a recovery connection of the user represented by id" + ), + id3: joi + .string() + .description( + "brightid of a recovery connection of the user represented by id" + ), + id4: joi + .string() + .description( + "brightid of a recovery connection of the user represented by id" + ), + id5: joi + .string() + .description( + "brightid of a recovery connection of the user represented by id" + ), + sig1: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the recovery connection represented by id1" + ), + sig2: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the recovery connection represented by id2" + ), + sig3: joi + .string() + .description( + "deterministic json representation of operation object signed by the recovery connection represented by id2" + ), + sig4: joi + .string() + .description( + "deterministic json representation of operation object signed by the recovery connection represented by id2" + ), + sig5: joi + .string() + .description( + "deterministic json representation of operation object signed by the recovery connection represented by id2" + ), + }, + Sponsor: { + appUserId: joi + .string() + .description("the app generated id that is being sponsored"), + id: joi + .string() + .description("brightid of the user who is requesting sponsorship"), + app: joi + .string() + .required() + .description("the app key that user is being sponsored by"), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the app keypair" + ), + }, + "Spend Sponsorship": { + appUserId: joi + .string() + .required() + .description("the app generated id that is being sponsored"), + app: joi + .string() + .required() + .description("the app key that user is being sponsored by"), + }, + Invite: { + inviter: joi + .string() + .required() + .description( + "brightid of the user who has admin rights in the group and can invite others to the group" + ), + invitee: joi + .string() + .required() + .description("brightid of the user whom is invited to the group"), + group: joi + .string() + .required() + .description( + "the unique id of the group that invitee is being invited to" + ), + data: joi + .string() + .required() + .description("the group AES key encrypted for signingKey of the invitee"), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the inviter" + ), + }, + Dismiss: { + dismisser: joi + .string() + .required() + .description( + "brightid of the user who has admin rights in the group and can dismiss others from the group" + ), + dismissee: joi + .string() + .required() + .description("brightid of the user whom is dismissed from the group"), + group: joi + .string() + .required() + .description( + "the unique id of the group that dismissee is being dismissed from" + ), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the dismisser" + ), + }, + "Add Admin": { + id: joi + .string() + .required() + .description("brightid of one of the current admins of the group"), + admin: joi + .string() + .required() + .description( + "brightid of the member whom is being granted administratorship of the group" + ), + group: joi.string().required().description("the unique id of the group"), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the admin user represented by id" + ), + }, + "Update Group": { + id: joi + .string() + .required() + .description("brightid of one of the admins of the group"), + group: joi.string().required().description("the unique id of the group"), + url: joi + .string() + .required() + .description( + "the new url that group data (profile image and name) encrypted by group AES key can be fetched from" + ), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id" + ), + }, + "Add Signing Key": { + id: joi + .string() + .required() + .description("brightid of the user who is adding new signingKey"), + signingKey: joi + .string() + .required() + .description( + "the public key of the new key pair that user can sign operations with" + ), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id" + ), + }, + "Remove Signing Key": { + id: joi + .string() + .required() + .description("brightid of the user who is removing the signingKey"), + signingKey: joi + .string() + .required() + .description("the signingKey that is being removed"), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id" + ), + }, + "Remove All Signing Keys": { + id: joi + .string() + .required() + .description( + "brightid of the user who is removing all the signingKeys except the one that used to sign this operation" + ), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id" + ), + }, + "Vouch Family": { + id: joi + .string() + .required() + .description("brightid of the user who is vouching the family group"), + group: joi.string().required().description("the unique id of the group"), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the user represented by id" + ), + }, + "Set Family Head": { + id: joi + .string() + .required() + .description("brightid of one of the current admins of the group"), + head: joi + .string() + .required() + .description( + "brightid of the member who is being granted the leadership of the family group" + ), + group: joi + .string() + .required() + .description("the unique id of the family group"), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the head user represented by id" + ), + }, + "Convert To Family": { + id: joi + .string() + .required() + .description("brightid of one of the current admins of the group"), + head: joi + .string() + .required() + .description( + "brightid of the member who is being granted the leadership of the family group" + ), + group: joi + .string() + .required() + .description("the unique id of the family group"), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the head user represented by id" + ), + }, + "Set Required Recovery Num": { + id: joi + .string() + .required() + .description( + "brightid of the user who is setting the required number of signatures for social recovery" + ), + requiredRecoveryNum: joi + .number() + .integer() + .greater(1) + .less(6) + .required() + .description("the required number of signatures for social recovery"), + sig: joi + .string() + .required() + .description( + "deterministic json representation of operation object signed by the head user represented by id" + ), + }, +}; + +Object.keys(operations).forEach((name) => { + operations[name] = Object.assign( + { + name: joi.string().valid(name).required().description("operation name"), + }, + operations[name], + { + timestamp: joi + .number() + .required() + .description( + "the timestamp (milliseconds since epoch) when the operation was created" + ), + v: joi.number().required().valid(6).description("API version"), + } + ); +}); + +// extend lower-level schemas with higher-level schemas +schemas = Object.assign( + { + connection: joi.object({ + id: joi.string().required().description("the brightid of the connection"), + level: joi.string().required().description("the level of the connection"), + timestamp: schemas.timestamp + .required() + .description("the timestamp of the connection"), + reportReason: joi + .string() + .valid("spammer", "fake", "duplicate", "deceased", "replaced", "other") + .description("for reported level, the reason for reporting"), + }), + invite: joi.object({ + id: joi.string().required().description("unique identifier of invite"), + group: joi + .string() + .required() + .description( + "unique identifier of the group that invitee is invited to" + ), + inviter: joi.string().required().description("brightid of inviter"), + invitee: joi.string().required().description("brightid of invitee"), + timestamp: schemas.timestamp + .required() + .description("timestamp when the user was invited"), + data: joi + .string() + .required() + .description( + "encrypted version of the AES key that group name and photo uploaded to `url` encrypted with" + + "invitee should first decrypt this data with his/her signingKey and then fetch data in `url` and decrypt that using the AES key" + ), + }), + app: joi.object({ + id: joi.string().required().description("unique app id"), + name: joi.string().required().description("app name"), + context: joi.string().description("the context of legacy apps"), + verification: joi + .string() + .required() + .description("verification required for using the app"), + verifications: joi + .array() + .items( + joi.string().description("verification required for using the app") + ), + verificationUrl: joi + .string() + .required() + .description("the url to PUT a verification with /:id"), + logo: joi.string().description("app logo (base64 encoded image)"), + url: joi.string().description("the base url for the app"), + assignedSponsorships: joi + .number() + .integer() + .description("number of assigned sponsorships"), + unusedSponsorships: joi + .number() + .integer() + .description("number of unused sponsorships"), + testing: joi.boolean().description("true if app is in testing mode"), + idsAsHex: joi + .boolean() + .description( + "true if app generated ids are in ethereum address format" + ), + usingBlindSig: joi + .boolean() + .description("true if app is using blind signature integration"), + verificationExpirationLength: joi + .number() + .integer() + .description("app verification expiration length in milliseconds"), + sponsorPublicKey: joi + .string() + .description( + "the public part of the key pair that the app uses to sign sponsor requests" + ), + nodeUrl: joi + .string() + .description( + "the url of the node that the app uses to query verification from" + ), + soulbound: joi + .boolean() + .required() + .description("true if the app uses soulbound standard"), + callbackUrl: joi.string().description("the callback url of the app"), + }), + recoveryConnection: joi.object({ + id: joi + .string() + .required() + .description("brightid of recovery connection"), + isActive: joi + .boolean() + .description("true if recovery connection active now"), + activeAfter: joi + .number() + .required() + .description("milliseconds until activation"), + activeBefore: joi + .number() + .required() + .description("milliseconds until inactivation"), + }), + report: joi.object({ + id: joi.string().required().description("brightid of the reporter"), + reason: joi + .string() + .required() + .valid("spammer", "fake", "duplicate", "deceased", "replaced", "other") + .description("the reason for reporting"), + }), + membership: joi.object({ + id: joi.string().required().description("the id of the group"), + timestamp: schemas.timestamp + .required() + .description("the timestamp when user joined the group"), + }), + }, + schemas +); + +schemas = Object.assign( + { + operation: joi + .alternatives() + .try( + Object.keys(operations).map((name) => + name==='Sponsor'? + joi.object(operations[name]).label(name).xor('id','appUserId'): + joi.object(operations[name]).label(name) + ) + ) + .description( + "Send operations to idchain to be applied to BrightID nodes' databases after consensus" + ), + }, + schemas +); + +// extend lower-level schemas with higher-level schemas +schemas = Object.assign( + { + operationPostResponse: joi.object({ + data: joi.object({ + hash: joi + .string() + .required() + .description( + "sha256 hash of the operation message used for generating signature" + ), + }), + }), + + userMembershipsGetResponse: joi.object({ + data: joi.object({ + memberships: joi.array().items(schemas.membership), + }), + }), + + userInvitesGetResponse: joi.object({ + data: joi.object({ + invites: joi.array().items(schemas.invite), + }), + }), + + userConnectionsGetResponse: joi.object({ + data: joi.object({ + connections: joi.array().items(schemas.connection), + }), + }), + + userFamiliesToVouchGetResponse: joi.object({ + data: joi.object({ + families: joi.array().items(joi.string()), + }), + }), + + userVerificationsGetResponse: joi.object({ + data: joi.object({ + verifications: joi.array().items(joi.object()), + }), + }), + + userProfileGetResponse: joi.object({ + data: joi.object({ + id: joi.string().description("brightid of the queried user"), + connectionsNum: joi + .number() + .integer() + .required() + .description( + "number of connections with already known or recovery level" + ), + groupsNum: joi + .number() + .integer() + .required() + .description("number of groups"), + createdAt: schemas.timestamp + .required() + .description("creation time of the user specified by id"), + reports: joi + .array() + .items(schemas.report) + .required() + .description( + "list of reporters of the user with the reason for each report" + ), + verifications: joi + .array() + .items(joi.object()) + .required() + .description( + "list of verification objects user has with properties each verification has" + ), + signingKeys: joi + .array() + .items(joi.string()) + .required() + .description( + "list of signing keys that user can sign operations with" + ), + recoveryConnections: joi + .array() + .items(schemas.recoveryConnection) + .required() + .description("list of recovery connections for the user"), + sponsored: joi.boolean().required().description("if user is sponsored"), + requiredRecoveryNum: joi + .number() + .integer() + .required() + .description("the required number of signatures for social recovery"), + mutualConnections: joi + .array() + .items(joi.string()) + .description("brightids of mutual connections"), + mutualGroups: joi + .array() + .items(joi.string()) + .description("ids of mutual groups"), + level: joi + .string() + .valid( + "reported", + "suspicious", + "just met", + "already known", + "recovery" + ) + .description( + "level of the connection from requestor to the user specified by id" + ), + connectedAt: schemas.timestamp.description( + "timestamp of the last connection from requestor to the user specified by id" + ), + }), + }), + + operationGetResponse: joi.object({ + data: joi.object({ + state: joi + .string() + .valid("init", "sent", "applied", "failed") + .description("state of operation"), + result: joi + .string() + .description( + "result of operation after being applied. If operation is failed this field contain the reason." + ), + }), + }), + + appGetResponse: joi.object({ + data: schemas.app, + }), + + allAppsGetResponse: joi.object({ + data: joi.object({ + apps: joi.array().items(schemas.app), + }), + }), + + stateGetResponse: joi.object({ + data: joi.object({ + lastProcessedBlock: joi + .number() + .integer() + .required() + .description("last block that consensus receiver service processed"), + verificationsBlock: joi + .number() + .integer() + .required() + .description( + "the block that scorer service updated verifications based on operations got applied before that block" + ), + initOp: joi + .number() + .integer() + .required() + .description("number of operations in the init state"), + sentOp: joi + .number() + .integer() + .required() + .description("number of operations in the sent state"), + verificationsHashes: joi + .array() + .items(joi.object()) + .required() + .description("different verifications' hashes for last 2 snapshots"), + wISchnorrPublic: joi + .string() + .required() + .description( + "the public part of WI-Schnorr params that should be used by client to generate challenge" + ), + ethSigningAddress: joi + .string() + .required() + .description( + "the ethereum address of this node; used for signing verifications" + ), + naclSigningKey: joi + .string() + .required() + .description( + "nacl signing key of this node; used for signing verifications" + ), + consensusSenderAddress: joi + .string() + .required() + .description( + "the ethereum address of consensus sender service of this node; used for sending operations" + ), + development: joi + .boolean() + .required() + .description("true if the node is in development mode"), + appsLastUpdateBlock: joi + .number() + .integer() + .required() + .description("the block that updater service updated the apps"), + sponsorshipsLastUpdateBlock: joi + .number() + .integer() + .required() + .description( + "the block that updater service updated the sponsorships" + ), + seedGroupsLastUpdateBlock: joi + .number() + .integer() + .required() + .description( + "the block that updater service updated the seed groups" + ), + version: joi.string().required().description("version of this node"), + }), + }), + + groupGetResponse: joi.object({ + data: joi.object({ + id: joi.string().required().description("the unique id of the group"), + members: joi + .array() + .items(joi.string()) + .required() + .description("brightids of members of the group"), + invites: joi.array().items(schemas.invite).required(), + admins: joi + .array() + .items(joi.string()) + .required() + .description("brightids of admins of the group"), + seed: joi.boolean().required().description("true if group is Seed"), + region: joi.string().description("region of the group"), + type: joi.string().required().description("type of the group"), + url: joi.string().required().description("url of the group"), + info: joi + .string() + .description("URL of a documnet that contains info about the group"), + timestamp: joi + .number() + .required() + .description("the group creation timestamp"), + }), + }), + + verificationPublicGetResponse: joi.object({ + data: joi.object({ + public: joi + .string() + .required() + .description( + "the public part of WI-Schnorr params that should be used by client to generate challenge" + ), + }), + }), + + verificationSigGetResponse: joi.object({ + data: joi.object({ + response: joi + .string() + .description( + "WI-Schnorr server response that will be used by client to generate final signature" + ), + }), + }), + + verificationAppUserIdPostBody: joi.object({ + uid: joi + .string() + .required() + .description( + "uid generated by client per app per expiration period and blind signed by node" + ), + sig: joi + .object({ + rho: joi.string().required(), + omega: joi.string().required(), + sigma: joi.string().required(), + delta: joi.string().required(), + }) + .required() + .description("unblinded sig"), + verification: joi + .string() + .required() + .description("verification required for using the app"), + roundedTimestamp: joi + .number() + .integer() + .required() + .description("timestamp that is rounded to app's required precision"), + }), + + verificationsGetResponse: joi.object({ + data: joi.array().items( + joi.object({ + unique: joi + .boolean() + .required() + .description("true if the user is unique under given app"), + app: joi.string().required().description("unique id of the app"), + appUserId: joi + .string() + .required() + .description("the id of the user within the app"), + verification: joi + .string() + .required() + .description("verification expression"), + verificationHash: joi + .string() + .description("sha256 of the verification expression"), + timestamp: schemas.timestamp.description( + "timestamp of the verification if a timestamp was requested" + ), + sig: joi + .string() + .description("verification message signed by the node"), + publicKey: joi.string().description("the node's public key"), + }) + ), + }), + + allVerificationsGetResponse: joi.object({ + data: joi.array().items( + joi.object({ + verification: joi + .string() + .required() + .description("the verification expression"), + appUserIds: joi + .array() + .items( + joi.string().description("the id of the user within the app") + ), + count: joi + .number() + .required() + .description("the number of app generated ids"), + }) + ), + }), + + sponsorshipGetResponse: joi.object({ + data: joi.object({ + app: joi + .string() + .required() + .description("the app key that user is being sponsored by"), + appHasAuthorized: joi + .boolean() + .required() + .description( + "true if the app authorized the node to use sponsorships for this app-generated id" + ), + spendRequested: joi + .boolean() + .required() + .description( + "true if the client requested to spend sponsorship for this app-generated id" + ), + timestamp: joi + .number() + .required() + .description("the sponsorship timestamp"), + }), + }), + + peersGetResponse: joi.object({ + data: joi.object({ + peers: joi + .array() + .items(joi.string()) + .required() + .description("list of other nodes that this node trusts"), + }), + }), + }, + schemas +); + +module.exports = { + schemas, + operations, +}; diff --git a/web_services/foxx/brightid6/tests/connections.js b/web_services/foxx/brightid6/tests/connections.js new file mode 100755 index 00000000..67c4c278 --- /dev/null +++ b/web_services/foxx/brightid6/tests/connections.js @@ -0,0 +1,458 @@ +"use strict"; + +const db = require("../db.js"); +const arango = require("@arangodb").db; +const usersColl = arango._collection("users"); +const connectionsColl = arango._collection("connections"); +const connectionsHistoryColl = arango._collection("connectionsHistory"); + +const chai = require("chai"); +const should = chai.should(); +const timestamp = Date.now(); + +describe("connections", function () { + before(function () { + usersColl.truncate(); + connectionsColl.truncate(); + connectionsHistoryColl.truncate(); + }); + after(function () { + usersColl.truncate(); + connectionsColl.truncate(); + connectionsHistoryColl.truncate(); + }); + it('should be able to "connect" using "just met" as confidence level', function () { + db.connect({ id1: "a", id2: "b", level: "just met", timestamp }); + db.connect({ id1: "b", id2: "a", level: "just met", timestamp }); + connectionsColl + .firstExample({ + _from: "users/a", + _to: "users/b", + }) + .level.should.equal("just met"); + connectionsColl + .firstExample({ + _from: "users/b", + _to: "users/a", + }) + .level.should.equal("just met"); + }); + it('should be able to use "connect" to upgrade confidence level to "already known"', function () { + db.connect({ id1: "b", id2: "a", level: "already known", timestamp }); + connectionsColl + .firstExample({ + _from: "users/b", + _to: "users/a", + }) + .level.should.equal("already known"); + }); + it('should be able to use "connect" to report a connection that already knows the reporter', function () { + db.connect({ + id1: "a", + id2: "b", + level: "reported", + reportReason: "duplicate", + timestamp, + }); + const conn = connectionsColl.firstExample({ + _from: "users/a", + _to: "users/b", + }); + conn.level.should.equal("reported"); + conn.reportReason.should.equal("duplicate"); + }); + it('should be able to use "connect" to reset confidence level to "just met"', function () { + db.connect({ id1: "a", id2: "b", level: "just met", timestamp }); + const conn1 = connectionsColl.firstExample({ + _from: "users/a", + _to: "users/b", + }); + conn1.level.should.equal("just met"); + (conn1.reportReason === null).should.equal(true); + }); + it('should be able to use "connect" to set different confidence levels', function () { + db.connect({ + id1: "a", + id2: "b", + level: "reported", + reportReason: "duplicate", + timestamp, + }); + connectionsColl + .firstExample({ + _from: "users/a", + _to: "users/b", + }) + .level.should.equal("reported"); + db.connect({ id1: "a", id2: "b", level: "just met", timestamp }); + connectionsColl + .firstExample({ + _from: "users/a", + _to: "users/b", + }) + .level.should.equal("just met"); + db.connect({ id1: "a", id2: "b", level: "recovery", timestamp }); + connectionsColl + .firstExample({ + _from: "users/a", + _to: "users/b", + }) + .level.should.equal("recovery"); + db.connect({ id1: "a", id2: "c", level: "just met", timestamp }); + connectionsColl + .firstExample({ + _from: "users/a", + _to: "users/c", + }) + .level.should.equal("just met"); + }); + + it('should be able to use "setSigningKey" to reset "signingKey" with "recovery" connections', function () { + db.connect({ id1: "c", id2: "a", level: "already known", timestamp }); + db.connect({ id1: "a", id2: "c", level: "recovery", timestamp }); + db.setSigningKey("newSigningKey", "a", ["b", "c"], timestamp); + usersColl.document("a").signingKeys.should.deep.equal(["newSigningKey"]); + }); + + it('should be able to get "userConnections"', function () { + db.connect({ + id1: "c", + id2: "a", + level: "reported", + reportReason: "duplicate", + timestamp: 0, + }); + const conns = db.userConnections("b"); + conns.length.should.equal(1); + const a = conns[0]; + a.id.should.equal("a"); + a.level.should.equal("already known"); + }); + + it("should be able to report someone as replaced", function () { + db.connect({ + id1: "c", + id2: "a", + level: "reported", + reportReason: "replaced", + replacedWith: "b", + timestamp, + }); + const conn = connectionsColl.firstExample({ + _from: "users/c", + _to: "users/a", + }); + conn.level.should.equal("reported"); + conn.reportReason.should.equal("replaced"); + conn.replacedWith.should.equal("b"); + }); +}); + +describe("recovery connections", function () { + before(function () { + usersColl.truncate(); + connectionsColl.truncate(); + connectionsHistoryColl.truncate(); + }); + after(function () { + usersColl.truncate(); + connectionsColl.truncate(); + connectionsHistoryColl.truncate(); + }); + + it("users should be able add or remove recovery connections", function () { + db.connect({ id1: "b", id2: "a", level: "already known", timestamp: 1 }); + db.connect({ id1: "a", id2: "b", level: "recovery", timestamp: 1 }); + db.connect({ + id1: "c", + id2: "a", + level: "already known", + timestamp: Date.now() - 30 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "a", + id2: "c", + level: "recovery", + timestamp: Date.now() - 30 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "d", + id2: "a", + level: "already known", + timestamp: Date.now() - 29 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "a", + id2: "d", + level: "recovery", + timestamp: Date.now() - 29 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "e", + id2: "a", + level: "already known", + timestamp: Date.now() - 28 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "a", + id2: "e", + level: "recovery", + timestamp: Date.now() - 28 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "f", + id2: "a", + level: "already known", + timestamp: Date.now() - 22 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "a", + id2: "f", + level: "recovery", + timestamp: Date.now() - 22 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "a", + id2: "b", + level: "reported", + reportReason: "duplicate", + timestamp: Date.now() - 22 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "c", + id2: "b", + level: "already known", + timestamp: Date.now() - 21 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "b", + id2: "c", + level: "recovery", + timestamp: Date.now() - 21 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "c", + id2: "d", + level: "already known", + timestamp: Date.now() - 20 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "d", + id2: "c", + level: "recovery", + timestamp: Date.now(), + }); + db.connect({ + id1: "b", + id2: "c", + level: "already known", + timestamp: Date.now(), + }); + db.connect({ + id1: "a", + id2: "e", + level: "already known", + timestamp: Date.now() - 5 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "a", + id2: "e", + level: "recovery", + timestamp: Date.now() - 2 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "g", + id2: "a", + level: "already known", + timestamp: Date.now() - 10 * 24 * 60 * 60 * 1000, + }); + db.connect({ + id1: "a", + id2: "g", + level: "recovery", + timestamp: Date.now() - 5 * 24 * 60 * 60 * 1000, + }); + + const recoveryConnections = db.getRecoveryConnections("a", "outbound"); + const activeRecoveryConnection = recoveryConnections + .filter((conn) => { + return conn.isActive; + }) + .map((conn) => { + return conn.id; + }); + activeRecoveryConnection.should.deep.equal(["c", "d", "e", "f"]); + recoveryConnections + .find((c) => c.id == "c") + .activeBefore.should.be.equal(0); + }); + + it("should not be able to add a recovery connection without cooling period", function () { + db.connect({ + id1: "a", + id2: "b", + level: "recovery", + timestamp: Date.now(), + }); + let recoveryConnections = db.getRecoveryConnections("a", "outbound"); + let activeRecoveryConnection = recoveryConnections + .filter((conn) => { + return conn.isActive; + }) + .map((conn) => { + return conn.id; + }); + activeRecoveryConnection.should.deep.equal(["c", "d", "e", "f"]); + + db.connect({ + id1: "a", + id2: "b", + level: "already known", + timestamp: Date.now(), + }); + recoveryConnections = db.getRecoveryConnections("a", "outbound"); + activeRecoveryConnection = recoveryConnections + .filter((conn) => { + return conn.isActive; + }) + .map((conn) => { + return conn.id; + }); + activeRecoveryConnection.should.deep.equal(["c", "d", "e", "f"]); + }); + + it("should not be able to inactive a recovery connection without cooling period", function () { + db.connect({ + id1: "a", + id2: "d", + level: "already known", + timestamp: Date.now(), + }); + let recoveryConnections = db.getRecoveryConnections("a", "outbound"); + let activeRecoveryConnection = recoveryConnections + .filter((conn) => { + return conn.isActive; + }) + .map((conn) => { + return conn.id; + }); + activeRecoveryConnection.should.deep.equal(["c", "d", "e", "f"]); + + db.connect({ + id1: "a", + id2: "d", + level: "recovery", + timestamp: Date.now(), + }); + recoveryConnections = db.getRecoveryConnections("a", "outbound"); + activeRecoveryConnection = recoveryConnections + .filter((conn) => { + return conn.isActive; + }) + .map((conn) => { + return conn.id; + }); + activeRecoveryConnection.should.deep.equal(["c", "d", "e", "f"]); + }); + + it("remove recovery connection should take one week to take effect to protect against takeover", function () { + db.connect({ + id1: "a", + id2: "c", + level: "reported", + reportReason: "duplicate", + timestamp: Date.now(), + }); + + const recoveryConnections = db.getRecoveryConnections("a", "outbound"); + recoveryConnections + .find((c) => c.id == "c") + .activeBefore.should.be.least(0); + const activeRecoveryConnection = recoveryConnections + .filter((conn) => { + return conn.isActive; + }) + .map((conn) => { + return conn.id; + }); + activeRecoveryConnection.should.deep.equal(["c", "d", "e", "f"]); + }); + + it("don't allow a recovery connection to be used for recovery if it is too new", function () { + db.connect({ + id1: "h", + id2: "a", + level: "already known", + timestamp: Date.now(), + }); + db.connect({ + id1: "a", + id2: "h", + level: "recovery", + timestamp: Date.now(), + }); + + const recoveryConnections = db.getRecoveryConnections("a", "outbound"); + recoveryConnections + .find((c) => c.id == "h") + .activeAfter.should.be.greaterThan(0); + const activeRecoveryConnection = recoveryConnections + .filter((conn) => { + return conn.isActive; + }) + .map((conn) => { + return conn.id; + }); + activeRecoveryConnection.should.deep.equal(["c", "d", "e", "f"]); + }); + + it("ignore cooling period from recovery connections set in the first day", function () { + connectionsColl.truncate(); + connectionsHistoryColl.truncate(); + const firstConnTime = Date.now() - 4 * 24 * 60 * 60 * 1000; + db.connect({ id1: "b", id2: "a", level: "already known", timestamp: 1 }); + db.connect({ + id1: "a", + id2: "b", + level: "recovery", + timestamp: firstConnTime, + }); + db.connect({ id1: "c", id2: "a", level: "already known", timestamp: 1 }); + db.connect({ + id1: "a", + id2: "c", + level: "recovery", + timestamp: firstConnTime + 5 * 60 * 60 * 1000, + }); + db.connect({ id1: "d", id2: "a", level: "already known", timestamp: 1 }); + db.connect({ + id1: "a", + id2: "d", + level: "recovery", + timestamp: firstConnTime + 22 * 60 * 60 * 1000, + }); + db.connect({ id1: "e", id2: "a", level: "already known", timestamp: 1 }); + db.connect({ + id1: "a", + id2: "e", + level: "recovery", + timestamp: firstConnTime + 30 * 60 * 60 * 1000, + }); + + const recoveryConnections = db.getRecoveryConnections("a", "outbound"); + recoveryConnections + .find((c) => c.id == "e") + .activeAfter.should.be.greaterThan(0); + recoveryConnections.find((c) => c.id == "b").activeAfter.should.be.equal(0); + recoveryConnections.find((c) => c.id == "c").activeAfter.should.be.equal(0); + recoveryConnections.find((c) => c.id == "d").activeAfter.should.be.equal(0); + const activeRecoveryConnection = recoveryConnections + .filter((conn) => { + return conn.isActive; + }) + .map((conn) => { + return conn.id; + }); + activeRecoveryConnection.should.deep.equal(["b", "c", "d"]); + }); +}); diff --git a/web_services/foxx/brightid6/tests/encoding.js b/web_services/foxx/brightid6/tests/encoding.js new file mode 100644 index 00000000..7a63e203 --- /dev/null +++ b/web_services/foxx/brightid6/tests/encoding.js @@ -0,0 +1,81 @@ +"use strict"; + +const enc = require("../encoding.js"); +const nacl = require("tweetnacl"); +const chai = require("chai"); +const should = chai.should(); +const expect = chai.expect; + +const b64ToUint8Array = enc.b64ToUint8Array; +const strToUint8Array = enc.strToUint8Array; + +describe("encoding", function () { + describe("Uint8Array", function () { + it(`should be defined`, function () { + expect(typeof Uint8Array).to.not.equal("undefined"); + }); + }); + + const s = "xyzABCDE"; + + describe(`The b64 string "${s}"`, function () { + describe("decoded as a Uint8Array", function () { + let e = ""; + try { + const array = Object.values(b64ToUint8Array(s)); + const expected_array = [199, 44, 192, 4, 32, 196]; + it(`should equal ${JSON.stringify(expected_array)}`, function () { + array.should.have.members(expected_array); + }); + it("should be a Uint8Array", function () { + expect(array instanceof Array); + }); + } catch (err) { + e = err; + } + it("should succeed", function () { + e.should.not.be.an("error"); + }); + }); + }); + + let safeB64 = []; + let regularB64 = []; + + safeB64[0] = "test-"; + regularB64[0] = "test+==="; + safeB64[1] = "_test-"; + regularB64[1] = "/test+=="; + safeB64[2] = "__test-"; + regularB64[2] = "//test+="; + safeB64[3] = "___test-"; + regularB64[3] = "///test+"; + + for (let i = 0; i <= 3; ++i) { + describe(`The url-safe b64 string "${safeB64[i]}"`, function () { + it(`should convert to the b64 string "${regularB64[i]}"`, function () { + enc.urlSafeB64ToB64(safeB64[i]).should.equal(regularB64[i]); + }); + }); + describe(`The b64 string "${regularB64[i]}"`, function () { + it(`should convert to the url-safe b64 string "${safeB64[i]}"`, function () { + enc.b64ToUrlSafeB64(regularB64[i]).should.equal(safeB64[i]); + }); + }); + } + + describe(`A b64 encoded publicKey and sig`, function () { + it(`should be usable by tweetnacl`, function () { + const publicKey = "zcsKbTYYKYc31hj/FCAmJlsizz2gOJRk+oOQYgQIpUg="; + const message = strToUint8Array("message"); + const sig = + "W9tcoxwdXr4er5FxH7LONOYKYSm1+DstAuhMhhuJXpzdlir5vbuIdfTAQDEABoyBqGtwhyKsKkMmsz8aD/wACQ=="; + (() => + nacl.sign.detached.verify( + message, + b64ToUint8Array(sig), + b64ToUint8Array(publicKey) + )).should.not.throw(); + }); + }); +}); diff --git a/web_services/foxx/brightid6/tests/errors.js b/web_services/foxx/brightid6/tests/errors.js new file mode 100644 index 00000000..e3460a2f --- /dev/null +++ b/web_services/foxx/brightid6/tests/errors.js @@ -0,0 +1,165 @@ +"use strict"; + +const db = require("../db.js"); +const _ = require("lodash"); +const { getMessage } = require("../operations"); +const errors = require("../errors"); +const arango = require("@arangodb").db; +const query = require("@arangodb").query; +const request = require("@arangodb/request"); +const nacl = require("tweetnacl"); +nacl.setPRNG(function (x, n) { + for (let i = 0; i < n; i++) { + x[i] = Math.floor(Math.random() * 256); + } +}); +const { + b64ToUrlSafeB64, + uInt8ArrayToB64, + strToUint8Array, + hash, +} = require("../encoding"); +const chai = require("chai"); + +const { baseUrl } = module.context; +const applyBaseUrl = baseUrl.replace("/brightid6", "/apply6"); + +const usersColl = arango._collection("users"); +const operationsColl = arango._collection("operations"); + +const should = chai.should(); + +const u1 = nacl.sign.keyPair(); +const u2 = nacl.sign.keyPair(); +const u3 = nacl.sign.keyPair(); +const u4 = nacl.sign.keyPair(); + +let { publicKey: sponsorPublicKey, secretKey: sponsorPrivateKey } = + nacl.sign.keyPair(); +let { secretKey: linkAESKey } = nacl.sign.keyPair(); + +function apply(op) { + let resp = request.post(`${baseUrl}/operations`, { + body: op, + json: true, + }); + if (resp.status != 200) { + return resp; + } + resp.status.should.equal(200); + let h = hash(getMessage(op)); + resp.json.data.hash.should.equal(h); + op = operationsColl.document(h); + op = _.omit(op, ["_rev", "_id", "_key", "hash", "state"]); + op.blockTime = op.timestamp; + resp = request.put(`${applyBaseUrl}/operations/${h}`, { + body: op, + json: true, + }); + resp.json.success.should.equal(true); + if ((resp.state = "failed")) { + return resp; + } +} + +describe("errors", function () { + before(function () { + usersColl.truncate(); + operationsColl.truncate(); + [u1, u2, u3, u4].map((u) => { + u.signingKey = uInt8ArrayToB64(Object.values(u.publicKey)); + u.id = b64ToUrlSafeB64(u.signingKey); + db.createUser(u.id, Date.now()); + }); + }); + + after(function () { + usersColl.truncate(); + operationsColl.truncate(); + }); + + it("should throw INVALID_SIGNATURE when operation signed by wrong user", function () { + const timestamp = Date.now(); + let op = { + v: 6, + name: "Connect", + id1: u2.id, + id2: u1.id, + level: "already known", + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + const resp = apply(op); + resp.json.code.should.equal(401); + resp.json.errorNum.should.equal(errors.INVALID_SIGNATURE); + }); + + it("should throw OperationNotFoundError when the operation does not exist", function () { + const hash = "testHash"; + const resp = request.get(`${baseUrl}/operations/${hash}`); + resp.json.code.should.equal(404); + resp.json.errorNum.should.equal(errors.OPERATION_NOT_FOUND); + resp.json.errorMessage.should.equal(`The operation ${hash} is not found.`); + }); + + it("should throw UserNotFoundError when the user does not exist", function () { + const id = "testId"; + const resp = request.get(`${baseUrl}/users/${id}/profile/dummy`); + resp.json.code.should.equal(404); + resp.json.errorNum.should.equal(errors.USER_NOT_FOUND); + resp.json.errorMessage.should.equal(`The user ${id} is not found.`); + }); + + it("should not be able to 'Social Recovery' when the signers' recovery connection set less than 7 days ago", function () { + db.connect({ + id1: u2.id, + id2: u1.id, + level: "already known", + timestamp: 1, + }); + db.connect({ id1: u1.id, id2: u2.id, level: "recovery", timestamp: 1 }); + db.connect({ + id1: u3.id, + id2: u1.id, + level: "already known", + timestamp: 1, + }); + db.connect({ id1: u1.id, id2: u3.id, level: "recovery", timestamp: 1 }); + db.connect({ + id1: u4.id, + id2: u1.id, + level: "already known", + timestamp: Date.now(), + }); + db.connect({ + id1: u1.id, + id2: u4.id, + level: "recovery", + timestamp: Date.now(), + }); + + const op = { + v: 6, + name: "Social Recovery", + id: u1.id, + id1: u2.id, + id2: u4.id, + signingKey: u4.signingKey, + timestamp: Date.now(), + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + op.sig2 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u3.secretKey)) + ); + apply(op); + const resp = apply(op); + resp.json.code.should.equal(403); + resp.json.errorNum.should.equal(errors.WAIT_FOR_COOLDOWN); + }); +}); diff --git a/web_services/foxx/brightid6/tests/groups.js b/web_services/foxx/brightid6/tests/groups.js new file mode 100755 index 00000000..6ea46336 --- /dev/null +++ b/web_services/foxx/brightid6/tests/groups.js @@ -0,0 +1,359 @@ +"use strict"; + +const db = require("../db.js"); +const errors = require("../errors.js"); +const arango = require("@arangodb").db; +const { hash } = require("../encoding"); + +const connectionsColl = arango._collection("connections"); +const groupsColl = arango._collection("groups"); +const usersInGroupsColl = arango._collection("usersInGroups"); +const usersColl = arango._collection("users"); +const invitationsColl = arango._collection("invitations"); + +const chai = require("chai"); +const should = chai.should(); +const expect = chai.expect; +const url = "http://url.com/dummy"; + +describe("groups", function () { + before(function () { + usersColl.truncate(); + connectionsColl.truncate(); + groupsColl.truncate(); + usersInGroupsColl.truncate(); + invitationsColl.truncate(); + db.createUser("a"); + db.createUser("b"); + db.createUser("c"); + db.createUser("d"); + db.createUser("e"); + }); + + after(function () { + usersColl.truncate(); + connectionsColl.truncate(); + groupsColl.truncate(); + usersInGroupsColl.truncate(); + invitationsColl.truncate(); + }); + + describe("creation", function () { + it("users should be able to create a group", function () { + db.createGroup("g1", "b", url, "general", Date.now()); + groupsColl.count().should.equal(1); + const group = groupsColl.any(); + group._key.should.equal("g1"); + }); + it("admin of the group should be able to delete it", function () { + db.deleteGroup("g1", "b", Date.now()); + groupsColl.count().should.equal(0); + }); + }); + + describe("invitation and joining", function () { + before(function () { + db.createGroup("g3", "a", url, "general", Date.now()); + }); + it("no one should be able to join a group without invitation", function () { + (() => { + db.addMembership("g3", "d", Date.now()); + }).should.throw(errors.NotInvitedError); + }); + it("admins should be able to invite any user to the group", function () { + db.invite("a", "d", "g3", "data", Date.now()); + db.userInvites("d") + .map((invite) => invite.group) + .should.deep.equal(["g3"]); + }); + it("invited user should be able to join the group", function () { + db.addMembership("g3", "d", Date.now()); + db.groupMembers("g3").should.include("d"); + db.userInvites("d").length.should.equal(0); + }); + it("non-admins should not be able to invite others to the group", function () { + (() => { + db.invite("d", "e", "g3", "data", Date.now()); + }).should.throw(errors.NotAdminError); + }); + }); + + describe("dismissing and leaving", function () { + before(function () { + db.invite("a", "b", "g3", "data", Date.now()); + db.invite("a", "d", "g3", "data", Date.now()); + db.invite("a", "e", "g3", "data", Date.now()); + db.addMembership("g3", "b", Date.now()); + db.addMembership("g3", "d", Date.now()); + db.addMembership("g3", "e", Date.now()); + }); + it("users should be able to leave the group", function () { + db.deleteMembership("g3", "b", Date.now()); + db.groupMembers("g3").should.not.include("b"); + usersInGroupsColl.count().should.equal(3); + }); + it("non-admins should not be able to dismiss others from the group", function () { + (() => { + db.dismiss("d", "e", "g3", Date.now()); + }).should.throw(errors.NotAdminError); + }); + it("admins should be able to dismiss others from the group", function () { + db.dismiss("a", "d", "g3", Date.now()); + db.groupMembers("g3").should.not.include("d"); + }); + }); + + describe("adding new admins", function () { + before(function () { + db.invite("a", "d", "g3", "data", Date.now()); + db.addMembership("g3", "d", Date.now()); + }); + it("non-admins should not be able to add new admins", function () { + (() => { + db.addAdmin("e", "d", "g3", Date.now()); + }).should.throw(errors.NotAdminError); + }); + it("admins should be able to add new admins", function () { + db.addAdmin("a", "d", "g3", Date.now()); + groupsColl.document("g3").admins.should.include("d"); + }); + it("admins should be removed from admins list when they leave the group", function () { + groupsColl.document("g3").admins.should.include("d"); + db.deleteMembership("g3", "d", Date.now()); + groupsColl.document("g3").admins.should.not.include("d"); + }); + }); + + describe("family groups", function () { + before(function () { + usersColl.truncate(); + connectionsColl.truncate(); + groupsColl.truncate(); + usersInGroupsColl.truncate(); + invitationsColl.truncate(); + db.connect({ + id1: "a1", + id2: "b1", + level: "already known", + timestamp: Date.now(), + }); + db.connect({ + id1: "b1", + id2: "a1", + level: "recovery", + timestamp: Date.now(), + }); + + db.connect({ + id1: "a1", + id2: "c1", + level: "already known", + timestamp: Date.now(), + }); + db.connect({ + id1: "c1", + id2: "a1", + level: "already known", + timestamp: Date.now(), + }); + + db.connect({ + id1: "d1", + id2: "e1", + level: "already known", + timestamp: Date.now(), + }); + db.connect({ + id1: "e1", + id2: "d1", + level: "recovery", + timestamp: Date.now(), + }); + + db.connect({ + id1: "d1", + id2: "a1", + level: "already known", + timestamp: Date.now(), + }); + db.connect({ + id1: "a1", + id2: "d1", + level: "already known", + timestamp: Date.now(), + }); + + db.connect({ + id1: "a1", + id2: "e1", + level: "already known", + timestamp: Date.now(), + }); + db.connect({ + id1: "e1", + id2: "a1", + level: "already known", + timestamp: Date.now(), + }); + + db.connect({ + id1: "f1", + id2: "e1", + level: "already known", + timestamp: Date.now(), + }); + db.connect({ + id1: "e1", + id2: "f1", + level: "already known", + timestamp: Date.now(), + }); + + db.connect({ + id1: "f1", + id2: "a1", + level: "already known", + timestamp: Date.now(), + }); + db.connect({ + id1: "a1", + id2: "f1", + level: "recovery", + timestamp: Date.now(), + }); + + db.connect({ + id1: "f1", + id2: "d1", + level: "already known", + timestamp: Date.now(), + }); + db.connect({ + id1: "d1", + id2: "f1", + level: "already known", + timestamp: Date.now(), + }); + + db.connect({ + id1: "e1", + id2: "h1", + level: "already known", + timestamp: Date.now(), + }); + db.connect({ + id1: "h1", + id2: "e1", + level: "already known", + timestamp: Date.now(), + }); + + db.connect({ + id1: "e1", + id2: "i1", + level: "already known", + timestamp: Date.now(), + }); + db.connect({ + id1: "i1", + id2: "e1", + level: "already known", + timestamp: Date.now(), + }); + }); + it("users should be able to found a family group", function () { + db.createGroup("fg1", "a1", url, "family", Date.now()); + groupsColl.count().should.equal(1); + }); + it("admins should be able to invite users which connected to all family group members", function () { + db.invite("a1", "b1", "fg1", "data", Date.now()); + db.groupInvites("fg1").length.should.equal(1); + }); + it("invited users which connected to all family group members should be able to join the group", function () { + db.addMembership("fg1", "b1", Date.now()); + const members = db.groupMembers("fg1"); + members.should.include("a1"); + members.should.include("b1"); + members.length.should.equal(2); + }); + it("users that are not connected to all members of the family groups should not be able to invite to the family group", function () { + (() => { + db.invite("a1", "c1", "fg1", "data", Date.now()); + }).should.throw(errors.IneligibleFamilyMember); + }); + it("admins of a family should be able to set an eligible user as head of the family", function () { + db.setFamilyHead("a1", "a1", "fg1"); + const group = db.getGroup("fg1"); + group.head.should.equal("a1"); + }); + it("head of a family group should be able be member of another family group", function () { + db.createGroup("fg2", "d1", url, "family", Date.now()); + db.invite("d1", "a1", "fg2", "data", Date.now()); + db.addMembership("fg2", "a1", Date.now()); + db.userMemberships("a1") + .map((group) => group.id) + .should.deep.equal(["fg1", "fg2"]); + groupsColl.count().should.equal(2); + }); + it("users that are member of family groups should not be able to invited to other family groups", function () { + (() => { + db.invite("d1", "b1", "fg2", "data", Date.now()); + }).should.throw(errors.AlreadyIsFamilyMember); + }); + it("family groups that do not have heads, ineligible to vouch for", function () { + (() => { + db.vouchFamily("f1", "fg2", Date.now()); + }).should.throw(errors.IneligibleToVouch); + }); + it("ineligible users should not be able to vouch family groups", function () { + (() => { + db.userFamiliesToVouch("e1").should.not.include("fg1"); + db.vouchFamily("e1", "fg1", Date.now()); + }).should.throw(errors.IneligibleToVouchFor); + }); + it("eligible users should be able to vouch family groups", function () { + db.setFamilyHead("d1", "d1", "fg2"); + db.userFamiliesToVouch("e1").should.include("fg2"); + db.vouchFamily("e1", "fg2", Date.now()); + groupsColl.document("fg2").vouchers.should.include("e1"); + }); + it("any changes in members of a family group should remove all already submitted vouches and vouchers should vouch again if they still eligible", function () { + groupsColl.document("fg2").vouchers.should.include("e1"); + db.invite("d1", "f1", "fg2", "data", Date.now()); + db.addMembership("fg2", "f1", Date.now()); + groupsColl.document("fg2").vouchers.length.should.equal(0); + db.userFamiliesToVouch("e1").should.include("fg2"); + db.vouchFamily("e1", "fg2", Date.now()); + groupsColl.document("fg2").vouchers.should.include("e1"); + }); + it("general groups should not be able to convert to a family if members already are members of another family", function () { + (() => { + db.createGroup("g3", "e1", url, "general", Date.now()); + db.invite("e1", "a1", "g3", "data", Date.now()); + db.addMembership("g3", "a1", Date.now()); + db.convertToFamily("e1", "e1", "g3", Date.now()); + }).should.throw(errors.AlreadyIsFamilyMember); + }); + it("general groups should not be able to convert to a family if all members are not connected to each other", function () { + (() => { + db.createGroup("g4", "e1", url, "general", Date.now()); + db.invite("e1", "i1", "g4", "data", Date.now()); + db.addMembership("g4", "i1", Date.now()); + db.invite("e1", "h1", "g4", "data", Date.now()); + db.addMembership("g4", "h1", Date.now()); + db.convertToFamily("e1", "e1", "g4", Date.now()); + }).should.throw(errors.IneligibleFamilyMember); + }); + it("admins of eligible general groups should be able to convert it to family", function () { + db.createGroup("g5", "e1", url, "general", Date.now()); + db.invite("e1", "f1", "g5", "data", Date.now()); + db.addMembership("g5", "f1", Date.now()); + let group = db.getGroup("g5"); + group.type.should.equal("general"); + db.convertToFamily("e1", "f1", "g5", Date.now()); + group = db.getGroup("g5"); + group.type.should.equal("family"); + group.head.should.equal("f1"); + }); + }); +}); diff --git a/web_services/foxx/brightid6/tests/operations.js b/web_services/foxx/brightid6/tests/operations.js new file mode 100755 index 00000000..d69cdebb --- /dev/null +++ b/web_services/foxx/brightid6/tests/operations.js @@ -0,0 +1,1066 @@ +"use strict"; + +const db = require("../db.js"); +const errors = require("../errors.js"); +const _ = require("lodash"); +const { getMessage } = require("../operations"); +const arango = require("@arangodb").db; +const query = require("@arangodb").query; +const request = require("@arangodb/request"); +const nacl = require("tweetnacl"); +const NodeRSA = require("node-rsa"); +const stringify = require("fast-json-stable-stringify"); +const BlindSignature = require("../rsablind"); + +nacl.setPRNG(function (x, n) { + for (let i = 0; i < n; i++) { + x[i] = Math.floor(Math.random() * 256); + } +}); +const { + b64ToUrlSafeB64, + uInt8ArrayToB64, + strToUint8Array, + b64ToUint8Array, + hash, +} = require("../encoding"); + +const { baseUrl } = module.context; +const applyBaseUrl = baseUrl.replace("/brightid6", "/apply6"); + +const connectionsColl = arango._collection("connections"); +const groupsColl = arango._collection("groups"); +const usersInGroupsColl = arango._collection("usersInGroups"); +const usersColl = arango._collection("users"); +const operationsColl = arango._collection("operations"); +const appsColl = arango._collection("apps"); +const sponsorshipsColl = arango._collection("sponsorships"); +const operationsHashesColl = arango._collection("operationsHashes"); +const invitationsColl = arango._collection("invitations"); +const verificationsColl = arango._collection("verifications"); +const operationCountersColl = arango._collection("operationCounters"); + +const chai = require("chai"); +const should = chai.should(); + +const u1 = nacl.sign.keyPair(); +const u2 = nacl.sign.keyPair(); +const u3 = nacl.sign.keyPair(); +const u4 = nacl.sign.keyPair(); +const u5 = nacl.sign.keyPair(); +const u6 = nacl.sign.keyPair(); +const u7 = nacl.sign.keyPair(); +const u8 = nacl.sign.keyPair(); +const u9 = nacl.sign.keyPair(); +const u10 = nacl.sign.keyPair(); +const u11 = nacl.sign.keyPair(); +const u12 = nacl.sign.keyPair(); + +let { publicKey: sponsorPublicKey, secretKey: sponsorPrivateKey } = + nacl.sign.keyPair(); + +function apply(op) { + let resp = request.post(`${baseUrl}/operations`, { + body: op, + json: true, + }); + resp.status.should.equal(200); + let h = hash(getMessage(op)); + resp.json.data.hash.should.equal(h); + op = operationsColl.document(h); + op = _.omit(op, ["_rev", "_id", "_key", "hash", "state"]); + op.blockTime = op.timestamp; + resp = request.put(`${applyBaseUrl}/operations/${h}`, { + body: op, + json: true, + }); + resp.json.success.should.equal(true); + return resp; +} + +function connect(u1, u2, level) { + const timestamp = Date.now(); + let op = { + v: 6, + name: "Connect", + id1: u1.id, + id2: u2.id, + level, + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + apply(op); + connectionsColl + .firstExample({ + _from: "users/" + u1.id, + _to: "users/" + u2.id, + }) + .level.should.equal(level); +} + +describe("operations", function () { + before(function () { + operationsHashesColl.truncate(); + usersColl.truncate(); + connectionsColl.truncate(); + groupsColl.truncate(); + usersInGroupsColl.truncate(); + operationsColl.truncate(); + appsColl.truncate(); + sponsorshipsColl.truncate(); + invitationsColl.truncate(); + verificationsColl.truncate(); + [u1, u2, u3, u4, u5, u6, u7, u8, u9, u10, u11, u12].forEach((u) => { + u.signingKey = uInt8ArrayToB64(Object.values(u.publicKey)); + u.id = b64ToUrlSafeB64(u.signingKey); + db.createUser(u.id, Date.now()); + }); + appsColl.insert({ + _key: "idchain", + sponsorPublicKey: uInt8ArrayToB64(Object.values(sponsorPublicKey)), + verificationExpirationLength: 1000000, + totalSponsorships: 3, + idsAsHex: true, + verifications: [ + 'SeedConnected and SeedConnected.rank>0' + ] + }); + + verificationsColl.insert({ + name: "SeedConnected", + user: u12.id, + rank: 3 + }); + + operationCountersColl.truncate(); + }); + + + after(function () { + operationsHashesColl.truncate(); + appsColl.truncate(); + usersColl.truncate(); + connectionsColl.truncate(); + groupsColl.truncate(); + usersInGroupsColl.truncate(); + operationsColl.truncate(); + sponsorshipsColl.truncate(); + invitationsColl.truncate(); + verificationsColl.truncate(); + operationCountersColl.truncate(); + }); + + it('should be able to "Connect"', function () { + connect(u1, u2, "just met"); + connect(u2, u1, "just met"); + connect(u1, u3, "just met"); + connect(u3, u1, "just met"); + connect(u2, u3, "just met"); + connect(u2, u4, "just met"); + connect(u3, u4, "just met"); + }); + + it('should be able to report using "Connect" by providing requestProof', function () { + const timestamp = Date.now(); + const requestProofMessage = u1.id + "|" + timestamp; + const requestProof = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(requestProofMessage), u1.secretKey) + ) + ); + let op = { + v: 6, + name: "Connect", + id1: u2.id, + id2: u1.id, + level: "reported", + reportReason: "spammer", + requestProof, + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + apply(op); + const conn = connectionsColl.firstExample({ + _from: "users/" + u2.id, + _to: "users/" + u1.id, + }); + conn.level.should.equal("reported"); + conn.requestProof.should.equal(requestProof); + }); + + it('should be able to "Add Group"', function () { + const timestamp = Date.now(); + const type = "general"; + const url = "http://url.com/dummy"; + const groupId = hash("randomstr"); + + const op = { + v: 6, + name: "Add Group", + group: groupId, + id: u1.id, + url, + type, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + apply(op); + + const members = db.groupMembers(groupId); + members.should.include(u1.id); + }); + + it('should be able to "Add Membership"', function () { + const groupId = db.userMemberships(u1.id)[0].id; + const timestamp = Date.now(); + db.invite(u1.id, u2.id, groupId, "data", timestamp); + const op = { + v: 6, + name: "Add Membership", + id: u2.id, + group: groupId, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + apply(op); + const members = db.groupMembers(groupId); + members.should.include(u2.id); + }); + + it('should be able to "Remove Membership"', function () { + const timestamp = Date.now(); + const groupId = db.userMemberships(u1.id)[0].id; + const op = { + v: 6, + name: "Remove Membership", + id: u2.id, + group: groupId, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + apply(op); + const members = db.groupMembers(groupId, false); + members.should.not.include(u2.id); + members.should.include(u1.id); + }); + + it('admins should be able to "Invite" someone to the group', function () { + const timestamp = Date.now(); + const groupId = db.userMemberships(u1.id)[0].id; + const data = "some data"; + const op = { + v: 6, + name: "Invite", + inviter: u1.id, + invitee: u2.id, + group: groupId, + data, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + apply(op); + invitationsColl + .byExample({ + _from: "users/" + u2.id, + _to: "groups/" + groupId, + }) + .count() + .should.equal(1); + }); + + it('admins should be able to "Dismiss" someone from the group', function () { + const timestamp = Date.now(); + const groupId = db.userMemberships(u1.id)[0].id; + db.addMembership(groupId, u2.id, Date.now()); + db.groupMembers(groupId).should.include(u2.id); + const op = { + v: 6, + name: "Dismiss", + dismisser: u1.id, + dismissee: u2.id, + group: groupId, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + apply(op); + db.groupMembers(groupId).should.not.include(u2.id); + }); + + it('admins should be able to "Add Admin" to the group', function () { + const timestamp = Date.now(); + const groupId = db.userMemberships(u1.id)[0].id; + db.invite(u1.id, u2.id, groupId, "data", Date.now()); + db.addMembership(groupId, u2.id, Date.now()); + db.groupMembers(groupId).should.include(u2.id); + const op = { + v: 6, + name: "Add Admin", + id: u1.id, + admin: u2.id, + group: groupId, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + apply(op); + groupsColl.document(groupId).admins.should.include(u2.id); + }); + + it('admins should be able "Update Group" to edit name and photo for groups', function () { + const newUrl = "http://url.com/newDummyUrl"; + const timestamp = Date.now(); + const groupId = db.userMemberships(u2.id)[0].id; + const op = { + v: 6, + name: "Update Group", + id: u2.id, + url: newUrl, + group: groupId, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + apply(op); + groupsColl.document(groupId).url.should.equal(newUrl); + }); + + it("should not be able to make a recovery connection when the other side connection is not equal to 'recovery' or 'already known'", function () { + const timestamp = Date.now(); + + let op = { + v: 6, + name: "Connect", + id1: u1.id, + id2: u2.id, + level: "recovery", + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + const resp = apply(op); + resp.json.result.errorNum.should.equal( + errors.INELIGIBLE_RECOVERY_CONNECTION + ); + connectionsColl + .firstExample({ + _from: "users/" + u1.id, + _to: "users/" + u2.id, + }) + .level.should.equal("just met"); + }); + + it('should be able to "Social Recovery" with 2 signers by default', function () { + connect(u2, u1, "already known"); + connect(u3, u1, "already known"); + connect(u1, u2, "recovery"); + connect(u1, u3, "recovery"); + const timestamp = Date.now(); + const op = { + v: 6, + name: "Social Recovery", + id: u1.id, + id1: u2.id, + id2: u3.id, + signingKey: u7.signingKey, + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + op.sig2 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u3.secretKey)) + ); + apply(op); + usersColl.document(u1.id).signingKeys.should.deep.equal([u7.signingKey]); + u1.secretKey = u7.secretKey; + }); + + it('should be able to "Set Required Recovery Num"', function () { + connect(u8, u1, "already known"); + connect(u1, u8, "recovery"); + const op = { + v: 6, + name: "Set Required Recovery Num", + id: u1.id, + requiredRecoveryNum: 3, + timestamp: Date.now(), + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + apply(op); + const user = usersColl.document(u1.id); + user.nextRequiredRecoveryNum.should.equal(3); + user.requiredRecoveryNumSetAfter.should.be.at.most( + Date.now() + 7 * 24 * 60 * 60 * 1000 + ); + }); + + it('should not be able to "Social Recovery" by wrong number of signers', function () { + // the 'requiredRecoveryNum' will set after 7 days so we put it manually for test + const user = usersColl.document(u1.id); + user.requiredRecoveryNum = user.nextRequiredRecoveryNum; + delete user.nextRequiredRecoveryNum; + delete user.requiredRecoveryNumSetAfter; + usersColl.replace(u1.id, user); + + const op = { + v: 6, + name: "Social Recovery", + id: u1.id, + id1: u2.id, + id2: u3.id, + signingKey: u4.signingKey, + timestamp: Date.now(), + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + op.sig2 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u3.secretKey)) + ); + + const resp = request.post(`${baseUrl}/operations`, { + body: op, + json: true, + }); + resp.json.errorNum.should.equal(errors.WRONG_NUMBER_OF_SIGNERS); + }); + + it('should be able to "Social Recovery" with the required number of signers (3)', function () { + const timestamp = Date.now(); + const op = { + v: 6, + name: "Social Recovery", + id: u1.id, + id1: u2.id, + id2: u3.id, + id3: u8.id, + signingKey: u4.signingKey, + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + op.sig2 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u3.secretKey)) + ); + op.sig3 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u8.secretKey)) + ); + apply(op); + usersColl.document(u1.id).signingKeys.should.deep.equal([u4.signingKey]); + u1.secretKey = u4.secretKey; + }); + + it('should be able to "Add Signing Key"', function () { + const addSigningKey = (u, signingKey) => { + const timestamp = Date.now(); + const op = { + v: 6, + id: u.id, + name: "Add Signing Key", + signingKey, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u.secretKey)) + ); + apply(op); + }; + addSigningKey(u2, u5.signingKey); + addSigningKey(u2, u6.signingKey); + usersColl + .document(u2.id) + .signingKeys.should.deep.equal([ + u2.signingKey, + u5.signingKey, + u6.signingKey, + ]); + }); + + it('should be able to "Remove Signing Key"', function () { + const timestamp = Date.now(); + const op = { + v: 6, + id: u2.id, + name: "Remove Signing Key", + signingKey: u5.signingKey, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u2.secretKey)) + ); + apply(op); + usersColl + .document(u2.id) + .signingKeys.should.deep.equal([u2.signingKey, u6.signingKey]); + }); + + it("should be able to sign an operation using new Signing Key", function () { + const timestamp = Date.now(); + let op = { + v: 6, + name: "Connect", + id1: u2.id, + id2: u3.id, + level: "reported", + reportReason: "duplicate", + timestamp, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u6.secretKey)) + ); + apply(op); + db.userConnections(u2.id) + .filter((u) => u.id == u3.id)[0] + .level.should.equal("reported"); + }); + + it('should be able to "Remove All Signing Keys"', function () { + const timestamp = Date.now(); + const op = { + v: 6, + id: u2.id, + name: "Remove All Signing Keys", + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u6.secretKey)) + ); + apply(op); + usersColl.document(u2.id).signingKeys.should.deep.equal([u6.signingKey]); + }); + + describe("family groups", function () { + before(function () { + usersColl.truncate(); + connectionsColl.truncate(); + groupsColl.truncate(); + usersInGroupsColl.truncate(); + invitationsColl.truncate(); + + connect(u7, u8, "already known"); + connect(u8, u7, "already known"); + + connect(u7, u9, "already known"); + connect(u9, u7, "already known"); + + connect(u8, u9, "already known"); + connect(u9, u8, "already known"); + + connect(u7, u10, "already known"); + connect(u10, u7, "already known"); + + connect(u10, u9, "already known"); + connect(u9, u10, "already known"); + + connect(u8, u10, "already known"); + connect(u10, u8, "already known"); + + connect(u11, u8, "already known"); + connect(u8, u11, "already known"); + + connect(u11, u10, "already known"); + connect(u10, u11, "already known"); + }); + + it('should be able to create a family group by "Add Group"', function () { + const timestamp = Date.now(); + const type = "family"; + const url = "http://url.com/dummy"; + const groupId = hash("randomstr1"); + const op = { + v: 6, + name: "Add Group", + group: groupId, + id: u7.id, + url, + type, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), u7.secretKey) + ) + ); + apply(op); + + const group = groupsColl.document(groupId); + group.type.should.equal("family"); + group.admins.should.include(u7.id); + }); + + it('admins should be able to "Invite" eligible users to the family group', function () { + const timestamp = Date.now(); + const groupId = hash("randomstr1"); + const data = "some data"; + const op = { + v: 6, + name: "Invite", + inviter: u7.id, + invitee: u8.id, + group: groupId, + data, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), u7.secretKey) + ) + ); + apply(op); + invitationsColl + .byExample({ + _from: "users/" + u8.id, + _to: "groups/" + groupId, + }) + .count() + .should.equal(1); + }); + + it('eligible users should be able to "Add Membership" to the family group', function () { + const timestamp = Date.now(); + const groupId = hash("randomstr1"); + const op = { + v: 6, + name: "Add Membership", + id: u8.id, + group: groupId, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), u8.secretKey) + ) + ); + apply(op); + const members = db.groupMembers(groupId); + members.should.include(u8.id); + }); + + it('admins should not be able to "Invite" ineligible users to the family group', function () { + const timestamp = Date.now(); + const groupId = hash("randomstr1"); + const data = "some data"; + const op = { + v: 6, + name: "Invite", + inviter: u7.id, + invitee: u11.id, + group: groupId, + data, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), u7.secretKey) + ) + ); + const resp = apply(op); + resp.json.result.errorNum.should.equal(errors.INELIGIBLE_FAMILY_MEMBER); + }); + + it('admins should be able to "Set Family Head" of the group', function () { + const timestamp = Date.now(); + const groupId = hash("randomstr1"); + + const op = { + v: 6, + name: "Set Family Head", + group: groupId, + id: u7.id, + head: u7.id, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), u7.secretKey) + ) + ); + apply(op); + const group = groupsColl.document(groupId); + group.head.should.equal(u7.id); + }); + + it('eligible users should be able to vouch family groups by "Vouch Family"', function () { + const timestamp = Date.now(); + const groupId = hash("randomstr1"); + db.userFamiliesToVouch(u9.id).should.include(groupId); + const op = { + v: 6, + name: "Vouch Family", + group: groupId, + id: u9.id, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), u9.secretKey) + ) + ); + apply(op); + groupsColl.document(groupId).vouchers.should.include(u9.id); + db.userFamiliesToVouch(u9.id).should.not.include(groupId); + }); + + it('admins should be able to change head of family by "Set Family Head"', function () { + const timestamp = Date.now(); + const groupId = hash("randomstr1"); + let group = groupsColl.document(groupId); + group.head.should.equal(u7.id); + + const op = { + v: 6, + name: "Set Family Head", + group: groupId, + id: u7.id, + head: u8.id, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), u7.secretKey) + ) + ); + apply(op); + group = groupsColl.document(groupId); + group.head.should.equal(u8.id); + }); + + it("after any changes (add/remove member or change head) in a family group all the vouchs will expired and eligible users can vouch again", function () { + const groupId = hash("randomstr1"); + groupsColl.document(groupId).vouchers.should.deep.equal([]); + db.userFamiliesToVouch(u9.id).should.include(groupId); + }); + + it("member of a family group should not be able to join another family group as member", function () { + let timestamp = Date.now(); + const type = "family"; + const url = "http://url.com/dummy"; + const groupId = hash("randomstr3"); + let op = { + v: 6, + name: "Add Group", + group: groupId, + id: u9.id, + url, + type, + timestamp, + }; + let message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), u9.secretKey) + ) + ); + apply(op); + + timestamp = Date.now(); + const data = "some data"; + op = { + v: 6, + name: "Invite", + inviter: u9.id, + invitee: u7.id, + group: groupId, + data, + timestamp, + }; + message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), u9.secretKey) + ) + ); + const resp = apply(op); + resp.json.result.errorNum.should.equal(errors.ALREADY_IS_FAMILY_MEMBER); + }); + + it("head of a family group should be able to join another family group as a member", function () { + let timestamp = Date.now(); + const groupId = hash("randomstr3"); + const data = "some data"; + let op = { + v: 6, + name: "Invite", + inviter: u9.id, + invitee: u8.id, + group: groupId, + data, + timestamp, + }; + let message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), u9.secretKey) + ) + ); + apply(op); + + timestamp = Date.now(); + op = { + v: 6, + name: "Add Membership", + id: u8.id, + group: groupId, + timestamp, + }; + message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), u8.secretKey) + ) + ); + apply(op); + let members = db.groupMembers(groupId); + members.should.include(u8.id); + const group1 = groupsColl.document(hash("randomstr1")); + group1.head.should.equal(u8.id); + }); + + it('admins of an eligible general group should be able to "Convert To Family"', function () { + const timestamp = Date.now(); + const groupId = hash("randomstr2"); + const url = "http://url.com/dummy"; + + db.createGroup(groupId, u10.id, url, "general", Date.now()); + db.invite(u10.id, u11.id, groupId, "data", Date.now()); + db.addMembership(groupId, u11.id, Date.now()); + let group = db.getGroup(groupId); + group.type.should.equal("general"); + + const op = { + v: 6, + name: "Convert To Family", + group: groupId, + id: u10.id, + head: u11.id, + timestamp, + }; + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(message), u10.secretKey) + ) + ); + apply(op); + group = groupsColl.document(groupId); + group.type.should.equal("family"); + group.head.should.equal(u11.id); + }); + }); + + describe("Sponsoring users", function () { + it('apps should be able to "Sponsor" first then clients "Spend Sponsorship"', function () { + const appUserId = "0x79aF508C9698076Bc1c2DfA224f7829e9768B11E"; + let op1 = { + name: "Sponsor", + appUserId, + app: "idchain", + timestamp: Date.now(), + v: 6, + }; + op1.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(stringify(op1)), sponsorPrivateKey) + ) + ); + apply(op1); + let resp1 = request.get(`${baseUrl}/sponsorships/${appUserId}`); + resp1.json.data.appHasAuthorized.should.equal(true); + resp1.json.data.spendRequested.should.equal(false); + + let op2 = { + name: "Spend Sponsorship", + appUserId: appUserId.toLowerCase(), + app: "idchain", + timestamp: Date.now(), + v: 6, + }; + apply(op2); + let resp2 = request.get(`${baseUrl}/sponsorships/${appUserId}`); + resp2.json.data.appHasAuthorized.should.equal(true); + resp2.json.data.spendRequested.should.equal(true); + appsColl.document("idchain").usedSponsorships.should.equal(1); + }); + + it('clients should be able to "Spend Sponsorship" first then apps "Sponsor"', function () { + const appUserId = "0xE8FB09228d1373f931007ca7894a08344B80901c"; + let op1 = { + name: "Spend Sponsorship", + appUserId, + app: "idchain", + timestamp: Date.now(), + v: 6, + }; + apply(op1); + let resp1 = request.get(`${baseUrl}/sponsorships/${appUserId}`); + resp1.json.data.spendRequested.should.equal(true); + resp1.json.data.appHasAuthorized.should.equal(false); + + let op3 = { + name: "Sponsor", + appUserId: appUserId.toLowerCase(), + app: "idchain", + timestamp: Date.now(), + v: 6, + }; + op3.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(stringify(op3)), sponsorPrivateKey) + ) + ); + apply(op3); + let resp3 = request.get( + `${baseUrl}/sponsorships/${appUserId.toLowerCase()}` + ); + resp3.json.data.appHasAuthorized.should.equal(true); + resp3.json.data.spendRequested.should.equal(true); + appsColl.document("idchain").usedSponsorships.should.equal(2); + }); + + it('should reject the duplicate sponsor requests which are received recently', function () { + const appUserId = "0x79aF508C9698076Bc1c2DfA224f7829e9768B11E"; + let op = { + name: "Sponsor", + appUserId, + app: "idchain", + timestamp: Date.now(), + v: 6, + }; + op.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(stringify(op)), sponsorPrivateKey) + ) + ); + let resp = request.post(`${baseUrl}/operations`, { + body: op, + json: true, + }); + resp.status.should.equal(403); + resp.json.errorNum.should.equal(errors.SPONSOR_REQUESTED_RECENTLY); + }); + + it('should reject the sponsor requests which are already sponsored', function () { + const appUserId = "0x79aF508C9698076Bc1c2DfA224f7829e9768B11F"; + // insert dummy sponsorship + sponsorshipsColl.insert({ + _from: "users/0", + _to: "apps/idchain", + appId: appUserId, + appHasAuthorized: true, + spendRequested: true, + timestamp: Date.now(), + }); + + let op = { + name: "Sponsor", + appUserId, + app: "idchain", + timestamp: Date.now(), + v: 6, + }; + op.sig = uInt8ArrayToB64( + Object.values( + nacl.sign.detached(strToUint8Array(stringify(op)), sponsorPrivateKey) + ) + ); + let resp = request.post(`${baseUrl}/operations`, { + body: op, + json: true, + }); + resp.status.should.equal(403); + resp.json.errorNum.should.equal(errors.SPONSORED_BEFORE); + }); + + it('should throw error because of XOR(id, appUserId)', function () { + const appUserId = "0x79aF508C9698076Bc1c2DfA224f7829e9768B11E"; + let op = { + name: "Sponsor", + app: "idchain", + timestamp: Date.now(), + id: u1.id, + appUserId: appUserId, + v: 6 + } + + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + + let resp = request.post(`${baseUrl}/operations`, { + body: op, + json: true, + }); + + resp.status.should.equal(400); + + }) + + it('should accept new sponsor operation without appUserId', function () { + let op = { + name: "Sponsor", + app: "idchain", + timestamp: Date.now(), + id: u12.id, + v: 6 + } + + const message = getMessage(op); + op.sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u12.secretKey)) + ); + let resp = apply(op); + resp.json.state.should.equal("applied"); + + + }) + + + + }); +}); diff --git a/web_services/foxx/brightid6/tests/replay_attack.js b/web_services/foxx/brightid6/tests/replay_attack.js new file mode 100755 index 00000000..beace3ca --- /dev/null +++ b/web_services/foxx/brightid6/tests/replay_attack.js @@ -0,0 +1,112 @@ +"use strict"; + +const stringify = require("fast-json-stable-stringify"); +const arango = require("@arangodb").db; +const { getMessage } = require("../operations"); +const errors = require("../errors"); +const request = require("@arangodb/request"); +const nacl = require("tweetnacl"); +nacl.setPRNG(function (x, n) { + for (let i = 0; i < n; i++) { + x[i] = Math.floor(Math.random() * 256); + } +}); +const { + strToUint8Array, + uInt8ArrayToB64, + b64ToUrlSafeB64, + hash, +} = require("../encoding"); +const db = require("../db.js"); + +const { baseUrl } = module.context; +const applyBaseUrl = baseUrl.replace("/brightid6", "/apply6"); + +const connectionsColl = arango._collection("connections"); +const groupsColl = arango._collection("groups"); +const usersInGroupsColl = arango._collection("usersInGroups"); +const usersColl = arango._collection("users"); +const operationsColl = arango._collection("operations"); + +const chai = require("chai"); +const should = chai.should(); + +const u1 = nacl.sign.keyPair(); +const u2 = nacl.sign.keyPair(); +const u3 = nacl.sign.keyPair(); +[u1, u2, u3].map((u) => { + u.signingKey = uInt8ArrayToB64(Object.values(u.publicKey)); + u.id = b64ToUrlSafeB64(u.signingKey); +}); + +describe("replay attack on operations", function () { + before(function () { + usersColl.truncate(); + connectionsColl.truncate(); + groupsColl.truncate(); + usersInGroupsColl.truncate(); + operationsColl.truncate(); + db.createUser(u1.id, u1.signingKey); + db.createUser(u2.id, u2.signingKey); + db.createUser(u3.id, u3.signingKey); + }); + after(function () { + usersColl.truncate(); + connectionsColl.truncate(); + groupsColl.truncate(); + usersInGroupsColl.truncate(); + operationsColl.truncate(); + }); + + it("should not be able to add an operation twice", function () { + const timestamp = Date.now(); + let op = { + name: "Connect", + id1: u1.id, + id2: u2.id, + level: "already known", + timestamp, + v: 6, + }; + const message = getMessage(op); + op.sig1 = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(message), u1.secretKey)) + ); + const resp1 = request.post(`${baseUrl}/operations`, { + body: op, + json: true, + }); + resp1.status.should.equal(200); + const h = hash(message); + resp1.json.data.hash.should.equal(h); + + op = operationsColl.document(h); + delete op._rev; + delete op._id; + delete op._key; + delete op.hash; + delete op.state; + op.blockTime = op.timestamp; + const resp2 = request.put(`${applyBaseUrl}/operations/${h}`, { + body: op, + json: true, + }); + resp2.json.success.should.equal(true); + resp2.json.state.should.equal("applied"); + delete op.blockTime; + + const resp3 = request.post(`${baseUrl}/operations`, { + body: op, + json: true, + }); + resp3.status.should.equal(403); + resp3.json.errorNum.should.equal(errors.OPERATION_APPLIED_BEFORE); + op.blockTime = op.timestamp; + + const resp4 = request.put(`${applyBaseUrl}/operations/${h}`, { + body: op, + json: true, + }); + resp4.json.result.errorNum.should.equal(errors.OPERATION_APPLIED_BEFORE); + }); +}); diff --git a/web_services/foxx/brightid6/tests/sponsorship.js b/web_services/foxx/brightid6/tests/sponsorship.js new file mode 100644 index 00000000..fa51ebc9 --- /dev/null +++ b/web_services/foxx/brightid6/tests/sponsorship.js @@ -0,0 +1,150 @@ +"use strict"; + +const db = require("../db.js"); +const errors = require("../errors.js"); +const arango = require("@arangodb").db; +const nacl = require("tweetnacl"); +nacl.setPRNG(function (x, n) { + for (let i = 0; i < n; i++) { + x[i] = Math.floor(Math.random() * 256); + } +}); + +const { + b64ToUrlSafeB64, + uInt8ArrayToB64, +} = require("../encoding"); + +const usersColl = arango._collection("users"); +const appsColl = arango._collection("apps"); +const sponsorshipsColl = arango._collection("sponsorships"); +const verificationsColl = arango._collection("verifications"); + + +const chai = require("chai"); +const should = chai.should(); + +const u1 = nacl.sign.keyPair(); +const u2 = nacl.sign.keyPair(); + +const app = "testapp"; + +let { publicKey: sponsorPublicKey, secretKey: sponsorPrivateKey } = + nacl.sign.keyPair(); +let { secretKey: linkAESKey } = nacl.sign.keyPair(); + +describe("New sponsorship routine", function () { + before(function () { + usersColl.truncate(); + appsColl.truncate(); + sponsorshipsColl.truncate(); + verificationsColl.truncate(); + + [u1, u2].forEach((u) => { + u.signingKey = uInt8ArrayToB64(Object.values(u.publicKey)); + u.id = b64ToUrlSafeB64(u.signingKey); + db.createUser(u.id, Date.now()); + }); + + + appsColl.insert({ + _key: app, + sponsorPublicKey: uInt8ArrayToB64(Object.values(sponsorPublicKey)), + verificationExpirationLength: 1000000, + totalSponsorships: 10, + idsAsHex: true, + verifications: [ + 'meets.rank > 1 and bitu.score > 2', + 'SeedConnected and SeedConnected.rank>0' + ] + }); + + + + verificationsColl.insert({ + name: "bitu", + user: u1.id, + score: 3, + }); + + verificationsColl.insert({ + name: "meets", + user: u1.id, + rank: 2, + }); + + verificationsColl.insert({ + name: "meets", + user: u2.id, + rank: 2, + }); + + }); + after(function () { + usersColl.truncate(); + appsColl.truncate(); + sponsorshipsColl.truncate(); + verificationsColl.truncate(); + + }); + + describe("Unit tests", function () { + describe("db.isVerifiedFor", function () { + it("should return true for the expected expr", function () { + const sampleExpr = `meets.rank>1 and bitu.score>2`; + db.isVerifiedFor(u1.id, sampleExpr).should.equal(true); + }); + it("should return false for the expected expr", function () { + const sampleExpr = `meets.rank>1 and bitu.score>3`; + db.isVerifiedFor(u1.id, sampleExpr).should.equal(false); + }); + it("should return false for the expected expr", function () { + const sampleExpr = `meets.rank>1 and bitu.score>2`; + db.isVerifiedFor(u2.id, sampleExpr).should.equal(false); + }); + }); + describe('db.sponor', function () { + it("should accept new sponsor operations", function () { + db.isSponsored(u1.id).should.equal(false); + const operation = { + id: u1.id, + app: app, + timestamp: Date.now(), + } + db.sponsor(operation); + db.isSponsored(u1.id).should.equal(true); + }); + it("should reject sponsor operation with verification error", function () { + db.isSponsored(u2.id).should.equal(false); + const operation = { + id: u2.id, + app: app, + timestamp: Date.now(), + }; + + (() => { + db.sponsor(operation) + }).should.throw(errors.NOT_VERIFIED); + + + }); + it("should reject sponsor operation with already sponsored error", function () { + db.isSponsored(u1.id).should.equal(true); + const operation = { + id: u1.id, + app: app, + timestamp: Date.now(), + }; + (() => { + db.sponsor(operation) + }).should.throw(errors.SPONSORED_BEFORE); + + }); + }); + + + }); + + + +}); diff --git a/web_services/foxx/brightid6/tests/time_window.js b/web_services/foxx/brightid6/tests/time_window.js new file mode 100755 index 00000000..17719b7b --- /dev/null +++ b/web_services/foxx/brightid6/tests/time_window.js @@ -0,0 +1,76 @@ +"use strict"; + +const operations = require("../operations.js"); +const db = require("../db.js"); +const errors = require("../errors"); +const arango = require("@arangodb").db; + +const usersColl = arango._collection("users"); +const connectionsColl = arango._collection("connections"); +const verificationsColl = arango._collection("verifications"); +const variablesColl = arango._collection("variables"); +const operationCountersColl = arango._collection("operationCounters"); +let hashes; + +const chai = require("chai"); +const should = chai.should(); + +describe("time window", function () { + before(function () { + usersColl.truncate(); + connectionsColl.truncate(); + verificationsColl.truncate(); + usersColl.insert({ _key: "a" }); + usersColl.insert({ _key: "b" }); + usersColl.insert({ _key: "c" }); + const hashes = JSON.parse( + variablesColl.document("VERIFICATIONS_HASHES").hashes + ); + const block = Math.max(...Object.keys(hashes)); + verificationsColl.insert({ name: "BrightID", user: "a", block }); + operationCountersColl.truncate(); + }); + after(function () { + usersColl.truncate(); + connectionsColl.truncate(); + verificationsColl.truncate(); + operationCountersColl.truncate(); + }); + it("should get error after limit", function () { + operations.checkLimits({ name: "Add Group", id: "a" }, 100, 2); + operations.checkLimits({ name: "Remove Group", id: "a" }, 100, 2); + (() => { + operations.checkLimits({ name: "Add Membership", id: "a" }, 100, 2); + }).should.throw(errors.TooManyOperationsError); + }); + it("unverified users should have shared limit", function () { + const now = Date.now(); + while (Date.now() - now <= 100); + operations.checkLimits({ name: "Add Group", id: "b" }, 100, 2); + operations.checkLimits({ name: "Add Group", id: "c" }, 100, 2); + (() => { + operations.checkLimits({ name: "Add Membership", id: "b" }, 100, 2); + }).should.throw(errors.TooManyOperationsError); + }); + it("connecting to first verified user should set parent", function () { + db.connect({ id1: "a", id2: "c", level: "just met", timestamp: 1 }); + usersColl.document("c").parent.should.equal("a"); + }); + it("unverified users with parent should have different limit", function () { + (() => { + operations.checkLimits({ name: "Add Membership", id: "b" }, 100, 2); + }).should.throw(errors.TooManyOperationsError); + operations.checkLimits({ name: "Add Group", id: "c" }, 100, 2); + operations.checkLimits({ name: "Add Group", id: "c" }, 100, 2); + (() => { + operations.checkLimits({ name: "Add Membership", id: "c" }, 100, 2); + }).should.throw(errors.TooManyOperationsError); + }); + it("every app should have different limit", function () { + operations.checkLimits({ name: "Sponsor", app: "app1" }, 100, 2); + operations.checkLimits({ name: "Sponsor", app: "app1" }, 100, 2); + (() => { + operations.checkLimits({ name: "Sponsor", app: "app1" }, 100, 2); + }).should.throw(errors.TooManyOperationsError); + }); +}); diff --git a/web_services/foxx/brightid6/tests/verifications.js b/web_services/foxx/brightid6/tests/verifications.js new file mode 100755 index 00000000..a311289e --- /dev/null +++ b/web_services/foxx/brightid6/tests/verifications.js @@ -0,0 +1,308 @@ +"use strict"; + +const stringify = require("fast-json-stable-stringify"); +const arango = require("@arangodb").db; +const request = require("@arangodb/request"); +const errors = require("../errors.js"); +const WISchnorrClient = require("../WISchnorrClient"); +const db = require("../db"); +const { + b64ToUrlSafeB64, + uInt8ArrayToB64, + strToUint8Array, + b64ToUint8Array, +} = require("../encoding"); +const chai = require("chai"); +const nacl = require("tweetnacl"); +nacl.setPRNG(function (x, n) { + for (let i = 0; i < n; i++) { + x[i] = Math.floor(Math.random() * 256); + } +}); + +const should = chai.should(); +const { baseUrl } = module.context; + +const usersColl = arango._collection("users"); +const appsColl = arango._collection("apps"); +const variablesColl = arango._collection("variables"); +const sponsorshipsColl = arango._collection("sponsorships"); +const verificationsColl = arango._collection("verifications"); +const cachedParamsColl = arango._collection("cachedParams"); +const appIdsColl = arango._collection("appIds"); + +const u1 = nacl.sign.keyPair(); +u1.signingKey = uInt8ArrayToB64(Object.values(u1.publicKey)); +u1.id = b64ToUrlSafeB64(u1.signingKey); + +const u2 = nacl.sign.keyPair(); +u2.signingKey = uInt8ArrayToB64(Object.values(u2.publicKey)); +u2.id = b64ToUrlSafeB64(u2.signingKey); + +const verificationExpirationLength = 1000000; + +const app = { + _key: "idchain", + verificationExpirationLength, + verifications: ["BrightID", "SeedConnected", "SeedConnectedWithFriend"], + usingBlindSig: true, + idsAsHex: true, +}; + +let info; + +describe("verifications", function () { + before(function () { + usersColl.truncate(); + appsColl.truncate(); + sponsorshipsColl.truncate(); + cachedParamsColl.truncate(); + appIdsColl.truncate(); + db.createUser(u1.id, 0); + db.createUser(u2.id, 0); + appsColl.insert(app); + sponsorshipsColl.insert({ + _from: `users/${u1.id}`, + _to: `apps/${app._key}`, + }); + if (!variablesColl.exists("LAST_BLOCK")) { + variablesColl.insert({ + _key: "LAST_BLOCK", + value: 0, + }); + } + const hashes = JSON.parse( + variablesColl.document("VERIFICATIONS_HASHES").hashes + ); + const block = Math.max(...Object.keys(hashes)); + verificationsColl.insert({ + user: u1.id, + name: "BrightID", + block, + }); + verificationsColl.insert({ + name: "SeedConnected", + user: u1.id, + rank: 3, + block, + }); + }); + + after(function () { + usersColl.truncate(); + appsColl.truncate(); + sponsorshipsColl.truncate(); + cachedParamsColl.truncate(); + appIdsColl.truncate(); + }); + + it("should not be able to get WI-Schnorr server response for unverified users", function () { + const client = new WISchnorrClient(db.getState().wISchnorrPublic); + let resp = request.get(`${baseUrl}/apps/${app._key}`); + const vel = resp.json.data.verificationExpirationLength; + const verifications = resp.json.data.verifications; + const appUserId = "0x79af508c9698076bc1c2dfa224f7829e9768b11e"; + + for (const verification of verifications) { + const info = { + app: app._key, + roundedTimestamp: parseInt(Date.now() / vel) * vel, + verification, + }; + resp = request.get(`${baseUrl}/verifications/blinded/public`, { + qs: info, + }); + const pub = JSON.parse(resp.body).data.public; + const uid = Math.random().toString(36).substr(2, 10); + const challenge = client.GenerateWISchnorrClientChallenge( + pub, + stringify(info), + uid + ); + const s = stringify({ id: u2.id, public: pub }); + const sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(s), u2.secretKey)) + ); + const qs = { + public: stringify(pub), + sig, + e: challenge.e, + }; + resp = request.get(`${baseUrl}/verifications/blinded/sig/${u2.id}`, { + qs, + }); + resp.json.errorNum.should.equal(errors.NOT_VERIFIED); + } + }); + + it("if the user is verified, apps should be able to get a verification signature", function () { + const client = new WISchnorrClient(db.getState().wISchnorrPublic); + let resp = request.get(`${baseUrl}/apps/${app._key}`); + const vel = resp.json.data.verificationExpirationLength; + const verifications = resp.json.data.verifications; + const appUserId = "0xE8FB09228d1373f931007ca7894a08344B80901c"; + + for (const verification of verifications) { + const info = { + app: app._key, + roundedTimestamp: parseInt(Date.now() / vel) * vel, + verification, + }; + resp = request.get(`${baseUrl}/verifications/blinded/public`, { + qs: info, + }); + const pub = JSON.parse(resp.body).data.public; + const uid = Math.random().toString(36).substr(2, 10); + const challenge = client.GenerateWISchnorrClientChallenge( + pub, + stringify(info), + uid + ); + const s = stringify({ id: u1.id, public: pub }); + const sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(s), u1.secretKey)) + ); + const qs = { + public: stringify(pub), + sig, + e: challenge.e, + }; + resp = request.get(`${baseUrl}/verifications/blinded/sig/${u1.id}`, { + qs, + }); + if (verification == "SeedConnectedWithFriend") { + resp.json.errorNum.should.equal(errors.NOT_VERIFIED); + continue; + } + const { response } = JSON.parse(resp.body).data; + const signature = client.GenerateWISchnorrBlindSignature( + challenge.t, + response + ); + + resp = request.post(`${baseUrl}/verifications/${info.app}/${appUserId}`, { + body: { + uid, + sig: signature, + verification, + roundedTimestamp: info.roundedTimestamp, + }, + json: true, + }); + resp.status.should.equal(204); + } + + resp = request.get(`${baseUrl}/verifications/${app._key}/${appUserId}`, { + qs: { + signed: "eth", + timestamp: "seconds", + }, + json: true, + }); + resp.status.should.equal(200); + for (let v of resp.json.data) { + Object.keys(v).should.include("verificationHash"); + if (v.verification == "SeedConnectedWithFriend") { + v.unique.should.equal(false); + } else { + v.unique.should.equal(true); + } + } + + resp = request.get( + `${baseUrl}/verifications/${app._key}/${appUserId.toLowerCase()}`, + { + qs: { + signed: "nacl", + }, + json: true, + } + ); + resp.status.should.equal(200); + for (let v of resp.json.data) { + Object.keys(v).should.include("verificationHash"); + if (v.verification == "SeedConnectedWithFriend") { + v.unique.should.equal(false); + } else { + v.unique.should.equal(true); + const message = v.app + "," + v.appUserId + "," + v.verificationHash; + nacl.sign.detached + .verify( + strToUint8Array(message), + b64ToUint8Array(v.sig), + b64ToUint8Array(v.publicKey) + ) + .should.equal(true); + } + } + }); + + it("should not be able get more than one signature per verification of the app in each expiration period", function () { + const info = { + app: app._key, + roundedTimestamp: + parseInt(Date.now() / verificationExpirationLength) * + verificationExpirationLength, + verification: "BrightID", + }; + const client = new WISchnorrClient(db.getState().wISchnorrPublic); + let resp = request.get(`${baseUrl}/verifications/blinded/public`, { + qs: info, + }); + const pub = JSON.parse(resp.body).data.public; + const uid = "unblinded_uid_of_the_user1"; + const appUserId = "0xE8FB09228d1373f931007ca7894a08344B80901c"; + const challenge = client.GenerateWISchnorrClientChallenge( + pub, + stringify(info), + uid + ); + const s = stringify({ id: u1.id, public: pub }); + const sig = uInt8ArrayToB64( + Object.values(nacl.sign.detached(strToUint8Array(s), u1.secretKey)) + ); + const qs = { + public: stringify(pub), + sig, + e: challenge.e, + }; + resp = request.get(`${baseUrl}/verifications/blinded/sig/${u1.id}`, { qs }); + resp.json.errorNum.should.equal(errors.DUPLICATE_SIG_REQUEST_ERROR); + }); + + it("apps should be able to check an appUserId verification", function () { + let appUserId = "0xE8FB09228d1373f931007ca7894a08344B80901c"; + let resp = request.get( + `${baseUrl}/verifications/${app._key}/${appUserId.toLowerCase()}`, + { + qs: { + signed: "eth", + timestamp: "seconds", + includeHash: false, + }, + json: true, + } + ); + resp.status.should.equal(200); + for (let v of resp.json.data) { + Object.keys(v).should.not.include("verificationHash"); + if (v.verification == "SeedConnectedWithFriend") { + v.unique.should.equal(false); + } else { + v.unique.should.equal(true); + } + } + + appUserId = "0x79aF508C9698076Bc1c2DfA224f7829e9768B11C"; + resp = request.get(`${baseUrl}/verifications/${app._key}/${appUserId}`, { + qs: { + signed: "eth", + timestamp: "seconds", + includeHash: false, + }, + json: true, + }); + resp.status.should.equal(404); + resp.json.errorNum.should.equal(errors.APP_ID_NOT_FOUND); + }); +}); diff --git a/web_services/foxx/package-lock.json b/web_services/foxx/package-lock.json new file mode 100644 index 00000000..fb18e62a --- /dev/null +++ b/web_services/foxx/package-lock.json @@ -0,0 +1,3435 @@ +{ + "name": "brightid-foxx-main", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "brightid-foxx-main", + "version": "1.0.0", + "dependencies": { + "fs-extra": "^11.2.0", + "unzipper": "^0.11.6" + }, + "devDependencies": { + "dotenv": "^10.0.0", + "foxx-cli": "^1.0.1" + } + }, + "node_modules/@types/node": { + "version": "20.14.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.2.tgz", + "integrity": "sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz", + "integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/ansi-regex": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz", + "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/arangojs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/arangojs/-/arangojs-6.14.1.tgz", + "integrity": "sha512-TJfqwLCo4RyXH5j3i491xKc6qBUsOhd3aIwrTMTuhMkzT6pGRYLvemrmM+XG5HlwYS33M0Ppdj3V6YBsk0HYYg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "es6-error": "^4.0.1", + "multi-part": "^2.0.0", + "x3-linkedlist": "1.0.0", + "xhr": "^2.4.1" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "dev": true, + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.reduce": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/array.prototype.reduce/-/array.prototype.reduce-1.0.7.tgz", + "integrity": "sha512-mzmiUCVwtiD4lgxYP8g7IYy8El8p2CSMePvIbTS7gchKir/L1fgJrk0yDKmAX6mnRQFKNADYIk8nNlTris5H1Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-array-method-boxes-properly": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dev": true, + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.0.tgz", + "integrity": "sha512-3AungXC4I8kKsS9PuS4JH2nc+0bVY/mjgrephHTIi8fpEeGsTHBUJeosp0Wc1myYMElmD0B3Oc4XL/HVJ4PV2g==", + "dev": true + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dev": true, + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==" + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "dev": true + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chardet": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.4.2.tgz", + "integrity": "sha512-j/Toj7f1z98Hh2cYo2BVr85EpIRWqUi7rtRSGxh/cqUjqrnJe9l9UE7IUGd2vQ2p+kSHLkSzObQPZPLUC6TQwg==", + "dev": true + }, + "node_modules/cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "dev": true, + "dependencies": { + "restore-cursor": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-width": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz", + "integrity": "sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==", + "dev": true + }, + "node_modules/cliui": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-4.1.0.tgz", + "integrity": "sha512-4FG+RSG9DL7uEwRUZXZn3SS34DiDPfzP0VOiEwtUWlE+AR2EIg+hSyvrIgUUfhdgR/UkAeW2QHgeP+hWrXs7jQ==", + "dev": true, + "dependencies": { + "string-width": "^2.1.1", + "strip-ansi": "^4.0.0", + "wrap-ansi": "^2.0.0" + } + }, + "node_modules/code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "dev": true, + "engines": [ + "node >= 0.8" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/concat-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/concat-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" + }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "dev": true, + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", + "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", + "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", + "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dedent": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", + "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", + "dev": true + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dom-walk": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", + "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==", + "dev": true + }, + "node_modules/dotenv": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", + "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "dev": true, + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/ejs": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-2.7.4.tgz", + "integrity": "sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA==", + "dev": true, + "hasInstallScript": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-abstract": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", + "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "arraybuffer.prototype.slice": "^1.0.3", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "data-view-buffer": "^1.0.1", + "data-view-byte-length": "^1.0.1", + "data-view-byte-offset": "^1.0.0", + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.0.3", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.4", + "get-symbol-description": "^1.0.2", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", + "has-symbols": "^1.0.3", + "hasown": "^2.0.2", + "internal-slot": "^1.0.7", + "is-array-buffer": "^3.0.4", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.1", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.3", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.13", + "is-weakref": "^1.0.2", + "object-inspect": "^1.13.1", + "object-keys": "^1.1.1", + "object.assign": "^4.1.5", + "regexp.prototype.flags": "^1.5.2", + "safe-array-concat": "^1.1.2", + "safe-regex-test": "^1.0.3", + "string.prototype.trim": "^1.2.9", + "string.prototype.trimend": "^1.0.8", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-length": "^1.0.1", + "typed-array-byte-offset": "^1.0.2", + "typed-array-length": "^1.0.6", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.15" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-array-method-boxes-properly": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", + "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", + "dev": true + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", + "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.4", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "dependencies": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true + }, + "node_modules/external-editor": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-2.2.0.tgz", + "integrity": "sha512-bSn6gvGxKt+b7+6TKEv1ZycHleA7aHhRHyAqJyp5pbUFuYYNIzpZnQDk7AsYckyWdEnTeAnay0aCy2aV6iTk9A==", + "dev": true, + "dependencies": { + "chardet": "^0.4.0", + "iconv-lite": "^0.4.17", + "tmp": "^0.0.33" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/extract-zip": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz", + "integrity": "sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==", + "dev": true, + "dependencies": { + "concat-stream": "^1.6.2", + "debug": "^2.6.9", + "mkdirp": "^0.5.4", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + } + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "dev": true, + "engines": [ + "node >=0.6.0" + ] + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/figures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/file-type": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-4.4.0.tgz", + "integrity": "sha512-f2UbFQEk7LXgWpi5ntcO86OeA/cC80fuDDDaX/fZ2ZGel+AF7leRQqBBW1eJNiiQkrZlAoM6P+VYP5P6bOlDEQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.3" + } + }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/foxx-cli": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/foxx-cli/-/foxx-cli-1.3.0.tgz", + "integrity": "sha512-sNA2lvsynSCCpTpVkkYnELqCux+K98vIphlNwNIjRe7dgfK+zP0xzHiHUTxIJ+QvP12UiIfIk45Why/fhaNeUw==", + "dev": true, + "dependencies": { + "arangojs": "^6.9.0", + "archiver": "^3.0.0", + "chalk": "^2.0.0", + "cliui": "^4.0.0", + "dedent": "^0.7.0", + "ejs": "^2.5.7", + "extract-zip": "^1.6.6", + "i": "^0.3.6", + "ini": "^1.3.4", + "inquirer": "^5.1.0", + "lodash": "^4.17.4", + "minimatch": "^3.0.4", + "request": "^2.85.0", + "semver": "^5.4.1", + "spdx-license-list": "^4.0.0", + "temp": "^0.8.3", + "util.promisify": "^1.0.0", + "yargs": "^12.0.2" + }, + "bin": { + "foxx": "bin/foxx" + } + }, + "node_modules/foxx-cli/node_modules/archiver": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-3.1.1.tgz", + "integrity": "sha512-5Hxxcig7gw5Jod/8Gq0OneVgLYET+oNHcxgWItq4TbhOzRLKNAFUb9edAftiMKXvXfCB0vbGrJdZDNq0dWMsxg==", + "dev": true, + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^2.6.3", + "buffer-crc32": "^0.2.1", + "glob": "^7.1.4", + "readable-stream": "^3.4.0", + "tar-stream": "^2.1.0", + "zip-stream": "^2.1.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/foxx-cli/node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "dev": true, + "dependencies": { + "lodash": "^4.17.14" + } + }, + "node_modules/foxx-cli/node_modules/compress-commons": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-2.1.1.tgz", + "integrity": "sha512-eVw6n7CnEMFzc3duyFVrQEuY1BlHR3rYsSztyG32ibGMW722i3C6IizEGMFmfMU+A+fALvBIwxN3czffTcdA+Q==", + "dev": true, + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^3.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^2.3.6" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/foxx-cli/node_modules/compress-commons/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/foxx-cli/node_modules/crc32-stream": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-3.0.1.tgz", + "integrity": "sha512-mctvpXlbzsvK+6z8kJwSJ5crm7yBwrQMTybJzMw1O4lLGJqjlDCXY2Zw7KheiA6XBEcBmfLx1D88mjRGVJtY9w==", + "dev": true, + "dependencies": { + "crc": "^3.4.4", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 6.9.0" + } + }, + "node_modules/foxx-cli/node_modules/get-caller-file": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", + "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==", + "dev": true + }, + "node_modules/foxx-cli/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, + "node_modules/foxx-cli/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/foxx-cli/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/foxx-cli/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true + }, + "node_modules/foxx-cli/node_modules/yargs": { + "version": "12.0.5", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-12.0.5.tgz", + "integrity": "sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw==", + "dev": true, + "dependencies": { + "cliui": "^4.0.0", + "decamelize": "^1.2.0", + "find-up": "^3.0.0", + "get-caller-file": "^1.0.1", + "os-locale": "^3.0.0", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^2.0.0", + "which-module": "^2.0.0", + "y18n": "^3.2.1 || ^4.0.0", + "yargs-parser": "^11.1.1" + } + }, + "node_modules/foxx-cli/node_modules/yargs-parser": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-11.1.1.tgz", + "integrity": "sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ==", + "dev": true, + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + }, + "node_modules/foxx-cli/node_modules/zip-stream": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-2.1.3.tgz", + "integrity": "sha512-EkXc2JGcKhO5N5aZ7TmuNo45budRaFGHOmz24wtJR7znbNqDPmdZtUauKX6et8KAVseAMBOyWJqEpXcHTBsh7Q==", + "dev": true, + "dependencies": { + "archiver-utils": "^2.1.0", + "compress-commons": "^2.1.1", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true + }, + "node_modules/fs-extra": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/get-symbol-description": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "dev": true, + "dependencies": { + "assert-plus": "^1.0.0" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/global": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", + "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", + "dev": true, + "dependencies": { + "min-document": "^2.19.0", + "process": "^0.11.10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, + "node_modules/har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "deprecated": "this library is no longer supported", + "dev": true, + "dependencies": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "dev": true, + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, + "node_modules/i": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/i/-/i-0.3.7.tgz", + "integrity": "sha512-FYz4wlXgkQwIPqhzC5TdNMLSE5+GS1IIDJZY/1ZiEPCT2S3COUVZeT5OW4BmW4r5LHLQuOosSwsvnroG9GR59Q==", + "dev": true, + "engines": { + "node": ">=0.4" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true + }, + "node_modules/inquirer": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-5.2.0.tgz", + "integrity": "sha512-E9BmnJbAKLPGonz0HeWHtbKf+EeSP93paWO3ZYoUpq/aowXvYGjjCSuashhXPpzbArIjBbji39THkxTz9ZeEUQ==", + "dev": true, + "dependencies": { + "ansi-escapes": "^3.0.0", + "chalk": "^2.0.0", + "cli-cursor": "^2.1.0", + "cli-width": "^2.0.0", + "external-editor": "^2.1.0", + "figures": "^2.0.0", + "lodash": "^4.3.0", + "mute-stream": "0.0.7", + "run-async": "^2.2.0", + "rxjs": "^5.5.2", + "string-width": "^2.1.0", + "strip-ansi": "^4.0.0", + "through": "^2.3.6" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/internal-slot": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.0", + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/invert-kv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-2.0.0.tgz", + "integrity": "sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", + "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", + "dev": true, + "dependencies": { + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/is-function": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz", + "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==", + "dev": true + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", + "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true + }, + "node_modules/is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "dev": true + }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "dev": true + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "dev": true, + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lcid": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-2.0.0.tgz", + "integrity": "sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA==", + "dev": true, + "dependencies": { + "invert-kv": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "dev": true + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "dev": true + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "dev": true + }, + "node_modules/map-age-cleaner": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz", + "integrity": "sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==", + "dev": true, + "dependencies": { + "p-defer": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/mem": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/mem/-/mem-4.3.0.tgz", + "integrity": "sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w==", + "dev": true, + "dependencies": { + "map-age-cleaner": "^0.1.1", + "mimic-fn": "^2.0.0", + "p-is-promise": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/mem/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-kind": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mime-kind/-/mime-kind-2.0.2.tgz", + "integrity": "sha512-2h7EUXQbwiub1XqpRcuMH105tWe2e7nadi1+1Tv6PLKP2MLOvuRFNR/9EeeAvhDEAASrZdvAT6G8SdqMmx0/Gw==", + "dev": true, + "dependencies": { + "file-type": "^4.3.0", + "mime-types": "^2.1.15" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/min-document": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", + "integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==", + "dev": true, + "dependencies": { + "dom-walk": "^0.1.0" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/multi-part": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/multi-part/-/multi-part-2.0.0.tgz", + "integrity": "sha512-3eFrYSu2BN2jZemde3cXzY41x/oyoAkVoZkWlN4qgI4nZbo10xTao4GMwy2X9UPo02r+GFGKXTppokynulhEBw==", + "dev": true, + "dependencies": { + "mime-kind": "^2.0.1" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mute-stream": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", + "integrity": "sha512-r65nCZhrbXXb6dXOACihYApHw2Q6pV0M3V0PSxd74N0+D8nzAdEAITq2oAjA1jVnKI+tGvEBUpqiMh0+rW6zDQ==", + "dev": true + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==", + "dev": true, + "dependencies": { + "path-key": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.getownpropertydescriptors": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.8.tgz", + "integrity": "sha512-qkHIGe4q0lSYMv0XI4SsBTJz3WaURhLvd0lKSgtVuOsJ2krg4SgMw3PIRQFMp07yi++UR3se2mkcLqsBNpBb/A==", + "dev": true, + "dependencies": { + "array.prototype.reduce": "^1.0.6", + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "gopd": "^1.0.1", + "safe-array-concat": "^1.1.2" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "dev": true, + "dependencies": { + "mimic-fn": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/os-locale": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-3.1.0.tgz", + "integrity": "sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q==", + "dev": true, + "dependencies": { + "execa": "^1.0.0", + "lcid": "^2.0.0", + "mem": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/p-defer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", + "integrity": "sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/p-is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-2.1.0.tgz", + "integrity": "sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-headers": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.5.tgz", + "integrity": "sha512-ft3iAoLOB/MlwbNXgzy43SWGP6sQki2jQvAyBg/zDFAgr9bfNWZIUj42Kw2eJIl8kEi4PbgE6U1Zau/HwI75HA==", + "dev": true + }, + "node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "dev": true + }, + "node_modules/possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "node_modules/psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", + "dev": true + }, + "node_modules/pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", + "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", + "dev": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", + "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "dev": true, + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz", + "integrity": "sha512-IqSUtOVP4ksd1C/ej5zeEh/BIP2ajqpn8c5x+q99gvcIG/Qf0cud5raVnE/Dwd0ua9TXYDoDc0RE5hBSdz22Ug==", + "dev": true + }, + "node_modules/restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "dev": true, + "dependencies": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/rxjs": { + "version": "5.5.12", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-5.5.12.tgz", + "integrity": "sha512-xx2itnL5sBbqeeiVgNPVuQQ1nC8Jp2WfNJhXWHmElW9YmrpS9UVnNzhP3EH3HFqexO5Tlp8GhYY+WEcqcVMvGw==", + "dev": true, + "dependencies": { + "symbol-observable": "1.0.1" + }, + "engines": { + "npm": ">=2.0.0" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", + "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safe-regex-test": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", + "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-regex": "^1.1.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/spdx-license-list": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/spdx-license-list/-/spdx-license-list-4.1.0.tgz", + "integrity": "sha512-nlyjgQUe1PgBGU0RdXIwo+N1VHI0XV/hxCBms8fhRDV7qottuPdX3gcTB4dpNnnQ/fIC3ymb/oWB6S8T6nQEnw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "dev": true, + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "dependencies": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", + "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", + "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==", + "dev": true, + "dependencies": { + "ansi-regex": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/symbol-observable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.0.1.tgz", + "integrity": "sha512-Kb3PrPYz4HanVF1LVGuAdW6LoVgIwjUYJGzFe7NDrBLCN4lsV/5J0MFurV+ygS4bRVwrCEt2c7MQ1R2a72oJDw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/temp": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.8.4.tgz", + "integrity": "sha512-s0ZZzd0BzYv5tLSptZooSjK8oj6C+c19p7Vqta9+6NPOf7r+fxq0cJe6/oN4LTC79sy5NY8ucOJNgwsKCSbfqg==", + "dev": true, + "dependencies": { + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true + }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "dev": true, + "dependencies": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "dev": true + }, + "node_modules/typed-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", + "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", + "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", + "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", + "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "dev": true + }, + "node_modules/unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unzipper": { + "version": "0.11.6", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.11.6.tgz", + "integrity": "sha512-anERl79akvqLbAxfjIFe4hK0wsi0fH4uGLwNEl4QEnG+KKs3QQeApYgOS/f6vH2EdACUlZg35psmd/3xL2duFQ==", + "dependencies": { + "big-integer": "^1.6.17", + "bluebird": "~3.4.1", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/util.promisify": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.1.2.tgz", + "integrity": "sha512-PBdZ03m1kBnQ5cjjO0ZvJMJS+QsbyIcFwi4hY4U76OQsCO9JrOYjbCFgIF76ccFg9xnJo7ZHPkqyj1GqmdS7MA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "object.getownpropertydescriptors": "^2.1.6", + "safe-array-concat": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "dev": true, + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "dependencies": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "dev": true + }, + "node_modules/which-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", + "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrap-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "integrity": "sha512-vAaEaDM946gbNpH5pLVNR+vX2ht6n0Bt3GXwVB1AuAqZosOvHNF3P7wDnh8KLkSqgUh0uh77le7Owgoz+Z9XBw==", + "dev": true, + "dependencies": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", + "dev": true, + "dependencies": { + "number-is-nan": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", + "dev": true, + "dependencies": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "dev": true, + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/x3-linkedlist": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/x3-linkedlist/-/x3-linkedlist-1.0.0.tgz", + "integrity": "sha512-8CwA4XCMtso4G6qJWCzqbWQ9YJjtRiD4rUHFJ77rlAXQUN38Ni9E84y4F9qt4ijxZhfpJVm9tRs8E2vdLC4ZqQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/xhr": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/xhr/-/xhr-2.6.0.tgz", + "integrity": "sha512-/eCGLb5rxjx5e3mF1A7s+pLlR6CGyqWN91fv1JgER5mVWg1MZmlhBvy9kjcsOdRk8RrIujotWyJamfyrp+WIcA==", + "dev": true, + "dependencies": { + "global": "~4.4.0", + "is-function": "^1.0.1", + "parse-headers": "^2.0.0", + "xtend": "^4.0.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "packages/apply5": { + "name": "brightid-foxx-apply5", + "version": "5.18.0", + "extraneous": true, + "dependencies": { + "base64-js": "^1.3.0", + "crypto-js": "^3.1.9-1", + "expr-eval": "^2.0.2", + "fast-json-stable-stringify": "^2.1.0", + "keccak": "^3.0.0", + "lodash": "^4.17.11", + "secp256k1": "^4.0.0", + "tweetnacl": "^1.0.0" + } + }, + "packages/apply6": { + "name": "brightid-foxx-apply6", + "version": "6.18.0", + "extraneous": true, + "dependencies": { + "base64-js": "^1.3.0", + "crypto-js": "^3.1.9-1", + "expr-eval": "^2.0.2", + "fast-json-stable-stringify": "^2.1.0", + "js-sha256": "0.9.0", + "jsbn": "1.1.0", + "keccak": "^3.0.0", + "lodash": "^4.17.11", + "node-rsa": "1.0.8", + "secp256k1": "^4.0.0", + "tweetnacl": "^1.0.0" + } + }, + "packages/brightid5": { + "name": "brightid-foxx-brightid5", + "version": "5.18.0", + "extraneous": true, + "dependencies": { + "base64-js": "^1.3.0", + "crypto-js": "^3.1.9-1", + "expr-eval": "^2.0.2", + "fast-json-stable-stringify": "^2.1.0", + "keccak": "^3.0.0", + "lodash": "^4.17.11", + "secp256k1": "^4.0.0", + "tweetnacl": "^1.0.0" + } + }, + "packages/brightid6": { + "name": "brightid-foxx-brightid6", + "version": "6.18.0", + "extraneous": true, + "dependencies": { + "base64-js": "^1.3.0", + "crypto-js": "^3.1.9-1", + "expr-eval": "^2.0.2", + "fast-json-stable-stringify": "^2.1.0", + "js-sha256": "0.9.0", + "jsbn": "1.1.0", + "keccak": "^3.0.0", + "lodash": "^4.17.11", + "node-rsa": "1.0.8", + "secp256k1": "^4.0.0", + "tweetnacl": "^1.0.0" + } + } + } +} diff --git a/web_services/foxx/package.json b/web_services/foxx/package.json new file mode 100644 index 00000000..a68a95b1 --- /dev/null +++ b/web_services/foxx/package.json @@ -0,0 +1,19 @@ +{ + "name": "brightid-foxx-main", + "version": "1.0.0", + "private": true, + "scripts": { + "deploy-services": "node scripts/deploy-services.js", + "download-services": "node scripts/download-services.js", + "set-dev": "node scripts/set-dev.js", + "test": "foxx test" + }, + "devDependencies": { + "dotenv": "^10.0.0", + "foxx-cli": "^1.0.1" + }, + "dependencies": { + "fs-extra": "^11.2.0", + "unzipper": "^0.11.6" + } +} diff --git a/web_services/foxx/scripts/config.js b/web_services/foxx/scripts/config.js new file mode 100644 index 00000000..7067d59c --- /dev/null +++ b/web_services/foxx/scripts/config.js @@ -0,0 +1,10 @@ +require('dotenv').config(); +const services = ['brightid5', 'apply5', 'brightid6', 'apply6']; +const serverArgs = (process.env.ARANGO_SERVER ? " --server " + process.env.ARANGO_SERVER : '') + + (process.env.ARANGO_DATABASE ? " --database " + process.env.ARANGO_DATABASE : '') + + (process.env.ARANGO_USERNAME ? " --username " + process.env.ARANGO_USERNAME : '') + + (process.env.ARANGO_PASSWORD ? " --password " + process.env.ARANGO_PASSWORD : ''); +module.exports = { + services, + serverArgs +} \ No newline at end of file diff --git a/web_services/foxx/scripts/deploy-services.js b/web_services/foxx/scripts/deploy-services.js new file mode 100644 index 00000000..9d2e1aee --- /dev/null +++ b/web_services/foxx/scripts/deploy-services.js @@ -0,0 +1,17 @@ +const { execSync } = require('child_process'); +const { services, serverArgs } = require("./config"); + +services.forEach(service => { + console.log(`Installing node_modules in ${service}...`); + execSync('npm install', { cwd: service, stdio: 'inherit' }); + console.log(`Deploying ${service} to ArangoDB...`); + try { + execSync(`foxx upgrade /${service} ${service}` + serverArgs, { stdio: 'pipe' }); + } catch (e) { + if(String(e).includes("No service found")) { + execSync(`foxx install /${service} ${service}` + serverArgs, { stdio: 'pipe' }); + } else { + throw e + } + } +}); diff --git a/web_services/foxx/scripts/download-services.js b/web_services/foxx/scripts/download-services.js new file mode 100644 index 00000000..675acc75 --- /dev/null +++ b/web_services/foxx/scripts/download-services.js @@ -0,0 +1,46 @@ +const fs = require('fs-extra'); +const unzipper = require('unzipper'); +const path = require('path'); +const { services, serverArgs } = require("./config"); +const {execSync} = require("child_process"); + +services.forEach(service => { + const parentDir = path.join(__dirname, '..'); + const folderPath = path.join(parentDir, service); + execSync(`foxx dl /${service} > ${service}.zip` + serverArgs, { stdio: 'inherit' }); + console.log(`Downloaded service /${service}`); + + const zipPath = path.join(parentDir, service + '.zip'); + console.log(`Created zip file: ${zipPath}`); + + async function unzip() { + try { + await fs.ensureDir(folderPath); + + const directory = await unzipper.Open.file(zipPath); + + for (const file of directory.files) { + const filePath = path.join(folderPath, file.path); + if (file.type === 'Directory') { + await fs.ensureDir(filePath); + } else if (file.type === 'File') { + const writeStream = fs.createWriteStream(filePath); + await new Promise((resolve, reject) => { + file.stream() + .pipe(writeStream) + .on('finish', resolve) + .on('error', reject); + }); + } + } + + console.log(`Unzipped content to folder: ${folderPath}`); + await fs.remove(zipPath); + console.log(`Removed zip file: ${zipPath}`); + } catch (err) { + console.error('Error:', err); + } + } + unzip(); +}) + diff --git a/web_services/foxx/scripts/set-dev.js b/web_services/foxx/scripts/set-dev.js new file mode 100644 index 00000000..68a14624 --- /dev/null +++ b/web_services/foxx/scripts/set-dev.js @@ -0,0 +1,7 @@ +const { execSync } = require('child_process'); +const { services, serverArgs } = require("./config"); + +services.forEach(service => { + console.log(`Setting dev mode for /${service}...`); + execSync(`foxx set-dev /${service}` + serverArgs, { stdio: 'pipe' }); +});