From b39664d4050ff2a6b1ddc9eab93adc1c3bdeddff Mon Sep 17 00:00:00 2001 From: Justin Berman Date: Sun, 13 Dec 2020 22:41:56 -0800 Subject: [PATCH 1/7] User can share a database by generating a share token (#157) - anyone with a database's shareToken can access the database --- src/userbase-js/src/Crypto/utils.js | 10 +- src/userbase-js/src/config.js | 2 +- src/userbase-js/src/db.js | 367 +++++++++++++++++++--------- src/userbase-js/src/errors/db.js | 67 ++++- src/userbase-js/src/ws.js | 44 +++- src/userbase-server/db.js | 220 ++++++++++++++--- src/userbase-server/server.js | 19 +- src/userbase-server/ws.js | 66 +++-- 8 files changed, 614 insertions(+), 181 deletions(-) diff --git a/src/userbase-js/src/Crypto/utils.js b/src/userbase-js/src/Crypto/utils.js index fb4ad5f6..49d753cb 100644 --- a/src/userbase-js/src/Crypto/utils.js +++ b/src/userbase-js/src/Crypto/utils.js @@ -2,8 +2,8 @@ const ONE_KB = 1024 const TEN_KB = 10 * ONE_KB // https://stackoverflow.com/a/20604561/11601853 -export const arrayBufferToString = (buf) => { - const bufView = new Uint16Array(buf) +export const arrayBufferToString = (buf, minified) => { + const bufView = minified ? new Uint8Array(buf) : new Uint16Array(buf) const length = bufView.length let result = '' let chunkSize = TEN_KB // using chunks prevents stack from blowing up @@ -20,9 +20,9 @@ export const arrayBufferToString = (buf) => { } // https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String -export const stringToArrayBuffer = (str) => { - let buf = new ArrayBuffer(str.length * 2) // 2 bytes for each char - let bufView = new Uint16Array(buf) +export const stringToArrayBuffer = (str, minified = false) => { + let buf = new ArrayBuffer(str.length * (minified ? 1 : 2)) // 2 bytes for each char, unless using minified. minified only safe for known input + let bufView = minified ? new Uint8Array(buf) : new Uint16Array(buf) for (let i = 0, strLen = str.length; i < strLen; i++) { bufView[i] = str.charCodeAt(i) } diff --git a/src/userbase-js/src/config.js b/src/userbase-js/src/config.js index 99fee59d..4d294316 100644 --- a/src/userbase-js/src/config.js +++ b/src/userbase-js/src/config.js @@ -1,6 +1,6 @@ import errors from './errors' -const USERBASE_JS_VERSION = '2.5.0' +const USERBASE_JS_VERSION = '2.6.0' const VERSION = '/v1' const DEFAULT_ENDPOINT = 'https://v1.userbase.com' + VERSION diff --git a/src/userbase-js/src/db.js b/src/userbase-js/src/db.js index 3947d5ec..2a489339 100644 --- a/src/userbase-js/src/db.js +++ b/src/userbase-js/src/db.js @@ -6,7 +6,7 @@ import ws from './ws' import errors from './errors' import statusCodes from './statusCodes' import { byteSizeOfString, Queue, objectHasOwnProperty } from './utils' -import { arrayBufferToString, stringToArrayBuffer } from './Crypto/utils' +import { appendBuffer, arrayBufferToString, stringToArrayBuffer } from './Crypto/utils' import api from './api' import config from './config' @@ -114,7 +114,7 @@ class UnverifiedTransaction { } class Database { - constructor(changeHandler, receivedMessage) { + constructor(changeHandler, receivedMessage, shareTokenHkdfKey) { this.onChange = _setChangeHandler(changeHandler) this.items = {} @@ -138,6 +138,7 @@ class Database { this.receivedMessage = receivedMessage this.usernamesByUserId = new Map() this.attributionEnabled = false + this.shareTokenHkdfKey = shareTokenHkdfKey // Queue that ensures 'ApplyTransactions' executes one at a time this.applyTransactionsQueue = new Queue() @@ -486,6 +487,12 @@ class Database { getFileVersionNumber(itemId) { return this.items[itemId].file && this.items[itemId].file.__v } + + async decryptShareTokenEncryptedDbKey(shareTokenEncryptedDbKey, shareTokenEncryptionKeySalt) { + const shareTokenEncryptionKey = await crypto.aesGcm.importKeyFromMaster(this.shareTokenHkdfKey, base64.decode(shareTokenEncryptionKeySalt)) + const dbKeyString = await crypto.aesGcm.decryptString(shareTokenEncryptionKey, shareTokenEncryptedDbKey) + return dbKeyString + } } const _setChangeHandler = (changeHandler) => { @@ -519,17 +526,46 @@ const _idempotentOpenDatabase = (database, changeHandler, receivedMessage) => { return false } -const _openDatabaseByDatabaseId = async (databaseId, changeHandler, receivedMessage) => { +const _getDatabaseIdFromShareToken = (shareTokenArrayBuffer) => { + const dbIdArrayBuffer = shareTokenArrayBuffer.slice(shareTokenArrayBuffer.byteLength - UUID_CHAR_LENGTH) + const databaseId = arrayBufferToString(dbIdArrayBuffer, true) + if (!databaseId || databaseId.length !== UUID_CHAR_LENGTH) throw new errors.ShareTokenInvalid + return databaseId +} + +const _getDatabaseIdAndShareToken = (shareTokenResult) => { + const shareTokenArrayBuffer = base64.decode(shareTokenResult) + const databaseId = _getDatabaseIdFromShareToken(shareTokenArrayBuffer) + const shareToken = shareTokenArrayBuffer.slice(0, shareTokenArrayBuffer.byteLength - UUID_CHAR_LENGTH) + return { databaseId, shareToken } +} + +const _openDatabaseByShareToken = async (shareTokenResult, changeHandler, receivedMessage) => { + let databaseIdAndShareToken, shareTokenHkdfKey + try { + databaseIdAndShareToken = _getDatabaseIdAndShareToken(shareTokenResult) + shareTokenHkdfKey = await crypto.hkdf.importHkdfKey(databaseIdAndShareToken.shareToken) + } catch { + throw new errors.ShareTokenInvalid + } + const { databaseId } = databaseIdAndShareToken + + const { validationMessage, signedValidationMessage } = await ws.authenticateShareToken(databaseId, shareTokenHkdfKey) + + await _openDatabaseByDatabaseId(databaseId, changeHandler, receivedMessage, shareTokenHkdfKey, validationMessage, signedValidationMessage) +} + +const _openDatabaseByDatabaseId = async (databaseId, changeHandler, receivedMessage, shareTokenHkdfKey, validationMessage, signedValidationMessage) => { const database = ws.state.databasesByDbId[databaseId] if (!database) { - ws.state.databasesByDbId[databaseId] = new Database(changeHandler, receivedMessage) + ws.state.databasesByDbId[databaseId] = new Database(changeHandler, receivedMessage, shareTokenHkdfKey) } else { if (_idempotentOpenDatabase(database, changeHandler, receivedMessage)) return } const action = 'OpenDatabaseByDatabaseId' - const params = { databaseId } + const params = { databaseId, validationMessage, signedValidationMessage } await ws.request(action, params) } @@ -556,11 +592,12 @@ const _openDatabase = async (changeHandler, params) => { timeout = setTimeout(() => reject(new Error('timeout')), 20000) }) + const { dbNameHash, newDatabaseParams, databaseId, shareToken } = params try { - const { dbNameHash, newDatabaseParams, databaseId } = params if (dbNameHash) await _openDatabaseByNameHash(dbNameHash, newDatabaseParams, changeHandler, receivedMessage) - else await _openDatabaseByDatabaseId(databaseId, changeHandler, receivedMessage) + else if (databaseId) await _openDatabaseByDatabaseId(databaseId, changeHandler, receivedMessage) + else if (shareToken) await _openDatabaseByShareToken(shareToken, changeHandler, receivedMessage) await firstMessageFromWebSocket } catch (e) { @@ -572,7 +609,8 @@ const _openDatabase = async (changeHandler, params) => { if (data === 'Database already creating') { throw new errors.DatabaseAlreadyOpening } else if (data === 'Database is owned by user') { - throw new errors.DatabaseIdNotAllowedForOwnDatabase + if (databaseId) throw new errors.DatabaseIdNotAllowedForOwnDatabase + else if (shareToken) throw new errors.ShareTokenNotAllowedForOwnDatabase } else if (data === 'Database key not found' || data === 'Database not found') { throw new errors.DatabaseNotFound } @@ -648,6 +686,7 @@ const _validateDbInput = (params) => { _validateDbName(params.databaseName) if (objectHasOwnProperty(params, 'databaseId')) throw new errors.DatabaseIdNotAllowed + if (objectHasOwnProperty(params, 'shareToken')) throw new errors.ShareTokenNotAllowed // try to block usage of verified users database. If user works around this and modifies this database, // they could mess up the database for themself. @@ -656,7 +695,12 @@ const _validateDbInput = (params) => { } } else if (objectHasOwnProperty(params, 'databaseId')) { + _validateDbId(params.databaseId) + if (objectHasOwnProperty(params, 'shareToken')) throw new errors.ShareTokenNotAllowed + + } else if (objectHasOwnProperty(params, 'shareToken')) { + if (typeof params.shareToken !== 'string') throw new errors.ShareTokenInvalid } else { throw new errors.DatabaseNameMissing } @@ -673,7 +717,7 @@ const openDatabase = async (params) => { _validateDbInput(params) if (!objectHasOwnProperty(params, 'changeHandler')) throw new errors.ChangeHandlerMissing - const { databaseName, databaseId, changeHandler, encryptionMode = ws.encryptionMode } = params + const { databaseName, databaseId, shareToken, changeHandler, encryptionMode = ws.encryptionMode } = params if (typeof changeHandler !== 'function') throw new errors.ChangeHandlerMustBeFunction _validateEncryptionMode(encryptionMode) @@ -689,9 +733,12 @@ const openDatabase = async (params) => { const openByDbNameHashParams = { dbNameHash, newDatabaseParams } await _openDatabase(changeHandler, openByDbNameHashParams) - } else { + } else if (databaseId) { const openByDbIdParams = { databaseId } await _openDatabase(changeHandler, openByDbIdParams) + } else { + const openByShareToken = { shareToken } + await _openDatabase(changeHandler, openByShareToken) } } catch (e) { @@ -708,6 +755,10 @@ const openDatabase = async (params) => { case 'DatabaseIdInvalidLength': case 'DatabaseIdNotAllowed': case 'DatabaseIdNotAllowedForOwnDatabase': + case 'ShareTokenNotAllowed': + case 'ShareTokenInvalid': + case 'ShareTokenExpired': + case 'ShareTokenNotAllowedForOwnDatabase': case 'DatabaseNotFound': case 'ChangeHandlerMissing': case 'ChangeHandlerMustBeFunction': @@ -729,13 +780,15 @@ const openDatabase = async (params) => { } } -const getOpenDb = (dbName, databaseId, encryptionMode = 'end-to-end') => { +const getOpenDb = (dbName, databaseId, shareToken, encryptionMode = 'end-to-end') => { _validateEncryptionMode(encryptionMode) + const dbId = !dbName && (databaseId || _getDatabaseIdFromShareToken(base64.decode(shareToken))) + const dbNameHash = encryptionMode === 'server-side' ? dbName : ws.state.dbNameToHash[dbName] const database = dbName ? ws.state.databases[dbNameHash] - : ws.state.databasesByDbId[databaseId] + : ws.state.databasesByDbId[dbId] if (!database || !database.init) throw new errors.DatabaseNotOpen return database @@ -745,7 +798,7 @@ const insertItem = async (params) => { try { _validateDbInput(params) - const database = getOpenDb(params.databaseName, params.databaseId, params.encryptionMode || ws.encryptionMode) + const database = getOpenDb(params.databaseName, params.databaseId, params.shareToken, params.encryptionMode || ws.encryptionMode) const action = 'Insert' const insertParams = await _buildInsertParams(database, params) @@ -766,6 +819,8 @@ const insertItem = async (params) => { case 'DatabaseIdCannotBeBlank': case 'DatabaseIdInvalidLength': case 'DatabaseIdNotAllowed': + case 'ShareTokenNotAllowed': + case 'ShareTokenInvalid': case 'DatabaseIsReadOnly': case 'EncryptionModeNotValid': case 'ServerSideEncryptionNotEnabledInClient': @@ -818,7 +873,7 @@ const updateItem = async (params) => { try { _validateDbInput(params) - const database = getOpenDb(params.databaseName, params.databaseId, params.encryptionMode || ws.encryptionMode) + const database = getOpenDb(params.databaseName, params.databaseId, params.shareToken, params.encryptionMode || ws.encryptionMode) const action = 'Update' const updateParams = await _buildUpdateParams(database, params) @@ -838,6 +893,8 @@ const updateItem = async (params) => { case 'DatabaseIdCannotBeBlank': case 'DatabaseIdInvalidLength': case 'DatabaseIdNotAllowed': + case 'ShareTokenNotAllowed': + case 'ShareTokenInvalid': case 'DatabaseIsReadOnly': case 'EncryptionModeNotValid': case 'ServerSideEncryptionNotEnabledInClient': @@ -892,7 +949,7 @@ const deleteItem = async (params) => { try { _validateDbInput(params) - const database = getOpenDb(params.databaseName, params.databaseId, params.encryptionMode || ws.encryptionMode) + const database = getOpenDb(params.databaseName, params.databaseId, params.shareToken, params.encryptionMode || ws.encryptionMode) const action = 'Delete' const deleteParams = await _buildDeleteParams(database, params) @@ -912,6 +969,8 @@ const deleteItem = async (params) => { case 'DatabaseIdCannotBeBlank': case 'DatabaseIdInvalidLength': case 'DatabaseIdNotAllowed': + case 'ShareTokenNotAllowed': + case 'ShareTokenInvalid': case 'DatabaseIsReadOnly': case 'EncryptionModeNotValid': case 'ServerSideEncryptionNotEnabledInClient': @@ -959,11 +1018,11 @@ const putTransaction = async (params) => { _validateDbInput(params) if (!objectHasOwnProperty(params, 'operations')) throw new errors.OperationsMissing - const { databaseName, databaseId, operations, encryptionMode = ws.encryptionMode } = params + const { databaseName, databaseId, shareToken, operations, encryptionMode = ws.encryptionMode } = params if (!Array.isArray(operations)) throw new errors.OperationsMustBeArray - const database = getOpenDb(databaseName, databaseId, encryptionMode) + const database = getOpenDb(databaseName, databaseId, shareToken, encryptionMode) const action = 'BatchTransaction' @@ -1018,6 +1077,8 @@ const putTransaction = async (params) => { case 'DatabaseIdCannotBeBlank': case 'DatabaseIdInvalidLength': case 'DatabaseIdNotAllowed': + case 'ShareTokenNotAllowed': + case 'ShareTokenInvalid': case 'DatabaseIsReadOnly': case 'EncryptionModeNotValid': case 'ServerSideEncryptionNotEnabledInClient': @@ -1190,7 +1251,7 @@ const uploadFile = async (params) => { try { _validateUploadFile(params) - const database = getOpenDb(params.databaseName, params.databaseId, params.encryptionMode || ws.encryptionMode) + const database = getOpenDb(params.databaseName, params.databaseId, params.shareToken, params.encryptionMode || ws.encryptionMode) const { dbId } = database try { @@ -1246,6 +1307,8 @@ const uploadFile = async (params) => { case 'DatabaseIdCannotBeBlank': case 'DatabaseIdInvalidLength': case 'DatabaseIdNotAllowed': + case 'ShareTokenNotAllowed': + case 'ShareTokenInvalid': case 'DatabaseIsReadOnly': case 'EncryptionModeNotValid': case 'ServerSideEncryptionNotEnabledInClient': @@ -1371,7 +1434,7 @@ const getFile = async (params) => { try { _validateGetFileParams(params) - const database = getOpenDb(params.databaseName, params.databaseId, params.encryptionMode || ws.encryptionMode) + const database = getOpenDb(params.databaseName, params.databaseId, params.shareToken, params.encryptionMode || ws.encryptionMode) const { dbId } = database const { fileId, range } = params @@ -1405,6 +1468,8 @@ const getFile = async (params) => { case 'DatabaseIdCannotBeBlank': case 'DatabaseIdInvalidLength': case 'DatabaseIdNotAllowed': + case 'ShareTokenNotAllowed': + case 'ShareTokenInvalid': case 'DatabaseIsReadOnly': case 'EncryptionModeNotValid': case 'ServerSideEncryptionNotEnabledInClient': @@ -1658,6 +1723,8 @@ const getDatabases = async (params) => { const { encryptionKey, ecdhPrivateKey } = ws.keys const username = ws.session.username + if (params && objectHasOwnProperty(params, 'shareToken')) throw new errors.ShareTokenNotAllowed + const encryptionMode = (params && params.encryptionMode) || ws.encryptionMode _validateEncryptionMode(encryptionMode) @@ -1700,6 +1767,7 @@ const getDatabases = async (params) => { case 'DatabaseIdCannotBeBlank': case 'DatabaseIdInvalidLength': case 'DatabaseIdNotAllowed': + case 'ShareTokenNotAllowed': case 'EncryptionModeNotValid': case 'ServerSideEncryptionNotEnabledInClient': case 'UserMustChangePassword': @@ -1718,8 +1786,8 @@ const _getDatabase = async (databaseName, databaseId, encryptionMode = 'end-to-e let database try { - // check if database is already open in memory - database = getOpenDb(databaseName, databaseId, encryptionMode) + // check if database is already open in memory. shareToken = null because not possible to pass shareToken here + database = getOpenDb(databaseName, databaseId, null, encryptionMode) } catch { // if not already open in memory, it's ok. Just get the values we need from backend const action = 'GetDatabases' @@ -1780,14 +1848,165 @@ const _verifyDatabaseRecipientFingerprint = async (username, recipientFingerprin if (!verifiedRecipientFingerprint) throw new errors.UserNotVerified } +const _getDatabaseEncryptionKey = async (database) => { + let dbKeyString + if (!database.dbKey) { + dbKeyString = database.plaintextDbKey || await crypto.aesGcm.decryptString(ws.keys.encryptionKey, database.encryptedDbKey) + database.dbKey = await crypto.aesGcm.getKeyFromKeyString(dbKeyString) + } else { + dbKeyString = await crypto.aesGcm.getKeyStringFromKey(database.dbKey) + } + return dbKeyString +} + +const _getShareToken = async (params, readOnly, encryptionMode) => { + try { + const { databaseName, databaseId } = params + + if (objectHasOwnProperty(params, 'requireVerified')) throw new errors.RequireVerifiedParamNotNecessary + if (objectHasOwnProperty(params, 'resharingAllowed')) throw new errors.ResharingAllowedParamNotAllowed('when retrieving a share token') + + // generate share token and associated keys + const shareToken = crypto.generateSeed() + const shareTokenHkdfKey = await crypto.hkdf.importHkdfKey(shareToken) + + // generate share token encryption key + const shareTokenEncryptionKeySalt = crypto.hkdf.generateSalt() + const shareTokenEncryptionKey = await crypto.aesGcm.importKeyFromMaster(shareTokenHkdfKey, shareTokenEncryptionKeySalt) + + // encrypt the database key using shareTokenEncryptionKey + const database = await _getDatabase(databaseName, databaseId, encryptionMode) + const dbKeyString = await _getDatabaseEncryptionKey(database) + const shareTokenEncryptedDbKeyString = await crypto.aesGcm.encryptString(shareTokenEncryptionKey, dbKeyString) + + // generate share token ECDSA key data + const { ecdsaPublicKey, encryptedEcdsaPrivateKey, ecdsaKeyEncryptionKeySalt } = await crypto.ecdsa.generateEcdsaKeyData(shareTokenHkdfKey) + + const action = 'ShareDatabaseToken' + const requestParams = { + databaseId: database.dbId, + databaseNameHash: database.dbNameHash, + readOnly, + keyData: { + shareTokenEncryptedDbKey: shareTokenEncryptedDbKeyString, + shareTokenEncryptionKeySalt: base64.encode(shareTokenEncryptionKeySalt), + shareTokenPublicKey: ecdsaPublicKey, + shareTokenEncryptedEcdsaPrivateKey: encryptedEcdsaPrivateKey, + shareTokenEcdsaKeyEncryptionKeySalt: ecdsaKeyEncryptionKeySalt, + } + } + await ws.request(action, requestParams) + + // append dbId to shareToken to get final shareToken to return to user, all in base64 + const dbIdArrayBuffer = stringToArrayBuffer(database.dbId, true) + const shareTokenResult = base64.encode(appendBuffer(shareToken, dbIdArrayBuffer)) + return shareTokenResult + } catch (e) { + _parseGenericErrors(e) + + if (e.response && e.response.data) { + switch (e.response.data.message) { + case 'DatabaseNotFound': throw new errors.DatabaseNotFound + case 'ResharingNotAllowed': throw new errors.ResharingNotAllowed('Only the owner can generate a share token') + } + } + + throw e + } +} + +const _shareDatabaseWithUsername = async (params, readOnly, resharingAllowed, requireVerified, encryptionMode) => { + const { databaseName, databaseId } = params + const username = params.username.toLowerCase() + + try { + // get recipient's public key to use to generate a shared key, and retrieve verified users list if requireVerified set to true + const [recipientPublicKey, verifiedUsers, database] = await Promise.all([ + api.auth.getPublicKey(username), + requireVerified && _openVerifiedUsersDatabase(), + _getDatabase(databaseName, databaseId, encryptionMode), + ]) + + // recipient must have required keys so client can share database key + if (!recipientPublicKey.ecdhPublicKey || !recipientPublicKey.ecdsaPublicKey) throw new errors.UserUnableToReceiveDatabase + + // compute recipient's fingerprint of ECDSA public key stored on server + const recipientRawEcdsaPublicKey = base64.decode(recipientPublicKey.ecdsaPublicKey) + const recipientFingerprint = await _getFingerprint(recipientRawEcdsaPublicKey) + + // verify that the recipient is in the user's list of verified users + if (requireVerified) await _verifyDatabaseRecipientFingerprint(username, recipientFingerprint, verifiedUsers) + + // verify recipient signed the ECDH public key that sender will be using to share database + const recipientEcdsaPublicKey = await crypto.ecdsa.getPublicKeyFromRawPublicKey(recipientRawEcdsaPublicKey) + const { signedEcdhPublicKey, ecdhPublicKey } = recipientPublicKey + const isVerified = await crypto.ecdsa.verify(recipientEcdsaPublicKey, base64.decode(signedEcdhPublicKey), base64.decode(ecdhPublicKey)) + + // this should never happen. If this happens, the server is serving conflicting keys and client should not sign anything + if (!isVerified) throw new errors.ServiceUnavailable + + const recipientEcdhPublicKey = await crypto.ecdh.getPublicKeyFromRawPublicKey(base64.decode(recipientPublicKey.ecdhPublicKey)) + + // generate ephemeral ECDH key pair to ensure forward secrecy for future shares between users if shared key is leaked + const ephemeralEcdhKeyPair = await crypto.ecdh.generateKeyPair() + const rawEphemeralEcdhPublicKey = await crypto.ecdh.getRawPublicKeyFromPublicKey(ephemeralEcdhKeyPair.publicKey) + const signedEphemeralEcdhPublicKey = await crypto.ecdsa.sign(ws.keys.ecdsaPrivateKey, rawEphemeralEcdhPublicKey) + + // compute shared key encryption key with recipient so can use it to encrypt database encryption key + const sharedKeyEncryptionKey = await crypto.ecdh.computeSharedKeyEncryptionKey(recipientEcdhPublicKey, ephemeralEcdhKeyPair.privateKey) + + // encrypt the database encryption key using shared ephemeral ECDH key + const dbKeyString = await _getDatabaseEncryptionKey(database) + const sharedEncryptedDbKeyString = await crypto.aesGcm.encryptString(sharedKeyEncryptionKey, dbKeyString) + + const action = 'ShareDatabase' + const requestParams = { + databaseId: database.dbId, + databaseNameHash: database.dbNameHash, + username, + readOnly, + resharingAllowed, + sharedEncryptedDbKey: sharedEncryptedDbKeyString, + ephemeralPublicKey: base64.encode(rawEphemeralEcdhPublicKey), + signedEphemeralPublicKey: base64.encode(signedEphemeralEcdhPublicKey), + sentSignature: await _signDbKeyAndFingerprint(database.dbKey, recipientFingerprint), + recipientEcdsaPublicKey: recipientPublicKey.ecdsaPublicKey + } + await ws.request(action, requestParams) + } catch (e) { + _parseGenericErrors(e) + + if (e.response && e.response.data) { + switch (e.response.data.message) { + case 'SharingWithSelfNotAllowed': + throw new errors.SharingWithSelfNotAllowed + case 'DatabaseNotFound': + throw new errors.DatabaseNotFound + case 'ResharingNotAllowed': + throw new errors.ResharingNotAllowed('Must have permission to reshare the database with another user') + case 'ResharingWithWriteAccessNotAllowed': + throw new errors.ResharingWithWriteAccessNotAllowed + case 'UserNotFound': + throw new errors.UserNotFound + case 'DatabaseAlreadyShared': + // safe to return + return + } + } + + throw e + } +} + const _validateUsername = (username) => { if (typeof username !== 'string') throw new errors.UsernameMustBeString if (username.length === 0) throw new errors.UsernameCannotBeBlank } const _validateDbSharingInput = (params) => { - if (!objectHasOwnProperty(params, 'username')) throw new errors.UsernameMissing - _validateUsername(params.username) + if (objectHasOwnProperty(params, 'shareToken')) throw new errors.ShareTokenNotAllowed + + if (objectHasOwnProperty(params, 'username')) _validateUsername(params.username) if (objectHasOwnProperty(params, 'readOnly') && typeof params.readOnly !== 'boolean') { throw new errors.ReadOnlyMustBeBoolean @@ -1807,99 +2026,18 @@ const shareDatabase = async (params) => { _validateDbInput(params) _validateDbSharingInput(params) - const { databaseName, databaseId, encryptionMode = ws.encryptionMode } = params - _validateEncryptionMode(encryptionMode) - const username = params.username.toLowerCase() const readOnly = objectHasOwnProperty(params, 'readOnly') ? params.readOnly : true const resharingAllowed = objectHasOwnProperty(params, 'resharingAllowed') ? params.resharingAllowed : false const requireVerified = objectHasOwnProperty(params, 'requireVerified') ? params.requireVerified : true - try { - // get recipient's public key to use to generate a shared key, and retrieve verified users list if requireVerified set to true - const [recipientPublicKey, verifiedUsers] = await Promise.all([ - api.auth.getPublicKey(username), - requireVerified && _openVerifiedUsersDatabase() - ]) - - // recipient must have required keys so client can share database key - if (!recipientPublicKey.ecdhPublicKey || !recipientPublicKey.ecdsaPublicKey) throw new errors.UserUnableToReceiveDatabase - - // compute recipient's fingerprint of ECDSA public key stored on server - const recipientRawEcdsaPublicKey = base64.decode(recipientPublicKey.ecdsaPublicKey) - const recipientFingerprint = await _getFingerprint(recipientRawEcdsaPublicKey) - - // verify that the recipient is in the user's list of verified users - if (requireVerified) await _verifyDatabaseRecipientFingerprint(username, recipientFingerprint, verifiedUsers) - - // verify recipient signed the ECDH public key that sender will be using to share database - const recipientEcdsaPublicKey = await crypto.ecdsa.getPublicKeyFromRawPublicKey(recipientRawEcdsaPublicKey) - const { signedEcdhPublicKey, ecdhPublicKey } = recipientPublicKey - const isVerified = await crypto.ecdsa.verify(recipientEcdsaPublicKey, base64.decode(signedEcdhPublicKey), base64.decode(ecdhPublicKey)) - - // this should never happen. If this happens, the server is serving conflicting keys and client should not sign anything - if (!isVerified) throw new errors.ServiceUnavailable - - const recipientEcdhPublicKey = await crypto.ecdh.getPublicKeyFromRawPublicKey(base64.decode(recipientPublicKey.ecdhPublicKey)) - - // generate ephemeral ECDH key pair to ensure forward secrecy for future shares between users if shared key is leaked - const ephemeralEcdhKeyPair = await crypto.ecdh.generateKeyPair() - const rawEphemeralEcdhPublicKey = await crypto.ecdh.getRawPublicKeyFromPublicKey(ephemeralEcdhKeyPair.publicKey) - const signedEphemeralEcdhPublicKey = await crypto.ecdsa.sign(ws.keys.ecdsaPrivateKey, rawEphemeralEcdhPublicKey) - - // compute shared key encryption key with recipient so can use it to encrypt database encryption key - const sharedKeyEncryptionKey = await crypto.ecdh.computeSharedKeyEncryptionKey(recipientEcdhPublicKey, ephemeralEcdhKeyPair.privateKey) - - // get the database encryption key - const database = await _getDatabase(databaseName, databaseId, encryptionMode) - let dbKeyString - if (!database.dbKey) { - dbKeyString = database.plaintextDbKey || await crypto.aesGcm.decryptString(ws.keys.encryptionKey, database.encryptedDbKey) - database.dbKey = await crypto.aesGcm.getKeyFromKeyString(dbKeyString) - } else { - dbKeyString = await crypto.aesGcm.getKeyStringFromKey(database.dbKey) - } - - // encrypt the database encryption key using shared ephemeral ECDH key - const sharedEncryptedDbKeyString = await crypto.aesGcm.encryptString(sharedKeyEncryptionKey, dbKeyString) - - const action = 'ShareDatabase' - const requestParams = { - databaseId: database.dbId, - databaseNameHash: database.dbNameHash, - username, - readOnly, - resharingAllowed, - sharedEncryptedDbKey: sharedEncryptedDbKeyString, - ephemeralPublicKey: base64.encode(rawEphemeralEcdhPublicKey), - signedEphemeralPublicKey: base64.encode(signedEphemeralEcdhPublicKey), - sentSignature: await _signDbKeyAndFingerprint(database.dbKey, recipientFingerprint), - recipientEcdsaPublicKey: recipientPublicKey.ecdsaPublicKey - } - await ws.request(action, requestParams) - } catch (e) { - _parseGenericErrors(e) - - if (e.response && e.response.data) { - switch (e.response.data.message) { - case 'SharingWithSelfNotAllowed': - throw new errors.SharingWithSelfNotAllowed - case 'DatabaseNotFound': - throw new errors.DatabaseNotFound - case 'ResharingNotAllowed': - throw new errors.ResharingNotAllowed - case 'ResharingWithWriteAccessNotAllowed': - throw new errors.ResharingWithWriteAccessNotAllowed - case 'UserNotFound': - throw new errors.UserNotFound - case 'DatabaseAlreadyShared': - // safe to return - return - } - } + const encryptionMode = params.encryptionMode || ws.encryptionMode + _validateEncryptionMode(encryptionMode) - throw e - } + let result = {} + if (objectHasOwnProperty(params, 'username')) await _shareDatabaseWithUsername(params, readOnly, resharingAllowed, requireVerified) + else result.shareToken = await _getShareToken(params, readOnly) + return result } catch (e) { switch (e.name) { @@ -1913,17 +2051,19 @@ const shareDatabase = async (params) => { case 'DatabaseIdCannotBeBlank': case 'DatabaseIdInvalidLength': case 'DatabaseIdNotAllowed': + case 'ShareTokenNotAllowed': case 'DatabaseNotFound': case 'EncryptionModeNotValid': case 'ServerSideEncryptionNotEnabledInClient': - case 'UsernameMissing': case 'UsernameCannotBeBlank': case 'UsernameMustBeString': case 'ReadOnlyMustBeBoolean': case 'ResharingAllowedMustBeBoolean': case 'ResharingNotAllowed': case 'ResharingWithWriteAccessNotAllowed': + case 'ResharingAllowedParamNotAllowed': case 'RequireVerifiedMustBeBoolean': + case 'RequireVerifiedParamNotNecessary': case 'SharingWithSelfNotAllowed': case 'UserMustChangePassword': case 'UserNotSignedIn': @@ -1945,13 +2085,15 @@ const modifyDatabasePermissions = async (params) => { _validateDbInput(params) _validateDbSharingInput(params) + if (!objectHasOwnProperty(params, 'username')) throw new errors.UsernameMissing + if (objectHasOwnProperty(params, 'revoke')) { if (typeof params.revoke !== 'boolean') throw new errors.RevokeMustBeBoolean // readOnly and resharingAllowed booleans have no use if revoking database from user if (params.revoke) { if (objectHasOwnProperty(params, 'readOnly')) throw new errors.ReadOnlyParamNotAllowed - if (objectHasOwnProperty(params, 'resharingAllowed')) throw new errors.ResharingAllowedParamNotAllowed + if (objectHasOwnProperty(params, 'resharingAllowed')) throw new errors.ResharingAllowedParamNotAllowed('when revoking access to a database') } } else if (!objectHasOwnProperty(params, 'readOnly') && !objectHasOwnProperty(params, 'resharingAllowed')) { throw new errors.ParamsMissing @@ -2010,6 +2152,7 @@ const modifyDatabasePermissions = async (params) => { case 'DatabaseIdCannotBeBlank': case 'DatabaseIdInvalidLength': case 'DatabaseIdNotAllowed': + case 'ShareTokenNotAllowed': case 'DatabaseNotFound': case 'EncryptionModeNotValid': case 'ServerSideEncryptionNotEnabledInClient': diff --git a/src/userbase-js/src/errors/db.js b/src/userbase-js/src/errors/db.js index 989fe76f..c1e9f888 100644 --- a/src/userbase-js/src/errors/db.js +++ b/src/userbase-js/src/errors/db.js @@ -180,6 +180,46 @@ class DatabaseIdInvalidLength extends Error { } } +class ShareTokenInvalid extends Error { + constructor(...params) { + super(...params) + + this.name = 'ShareTokenInvalid' + this.message = 'Share token invalid.' + this.status = statusCodes['Bad Request'] + } +} + +class ShareTokenExpired extends Error { + constructor(...params) { + super(...params) + + this.name = 'ShareTokenExpired' + this.message = 'Share token expired. The database owner has generated a new share token.' + this.status = statusCodes['Forbidden'] + } +} + +class ShareTokenNotAllowed extends Error { + constructor(reason, ...params) { + super(reason, ...params) + + this.name = 'ShareTokenNotAllowed' + this.message = 'Share token not allowed.' + this.status = statusCodes['Bad Request'] + } +} + +class ShareTokenNotAllowedForOwnDatabase extends Error { + constructor(...params) { + super(...params) + + this.name = 'ShareTokenNotAllowedForOwnDatabase' + this.message = "Tried to open the user's own database using its shareToken rather than its databaseName. The shareToken should only be used to open databases shared from other users." + this.status = statusCodes['Forbidden'] + } +} + class ReadOnlyMustBeBoolean extends Error { constructor(...params) { super(...params) @@ -201,11 +241,11 @@ class ReadOnlyParamNotAllowed extends Error { } class ResharingAllowedParamNotAllowed extends Error { - constructor(...params) { - super(...params) + constructor(reason, ...params) { + super(reason, ...params) this.name = 'ResharingAllowedParamNotAllowed' - this.message = 'Resharing allowed parameter not allowed when revoking access to a database.' + this.message = `Resharing allowed parameter not allowed ${reason}.` this.status = statusCodes['Bad Request'] } } @@ -221,11 +261,11 @@ class ResharingAllowedMustBeBoolean extends Error { } class ResharingNotAllowed extends Error { - constructor(...params) { - super(...params) + constructor(reason, ...params) { + super(reason, ...params) this.name = 'ResharingNotAllowed' - this.message = 'Resharing not allowed. Must have permission to reshare the database with another user.' + this.message = `Resharing not allowed. ${reason}.` this.status = statusCodes['Forbidden'] } } @@ -300,6 +340,16 @@ class RequireVerifiedMustBeBoolean extends Error { } } +class RequireVerifiedParamNotNecessary extends Error { + constructor(...params) { + super(...params) + + this.name = 'RequireVerifiedParamNotNecessary' + this.message = 'Require verified parameter not necessary when sharing database without a username.' + this.status = statusCodes['Bad Request'] + } +} + class RevokeMustBeBoolean extends Error { constructor(...params) { super(...params) @@ -734,6 +784,10 @@ export default { DatabaseIdNotAllowed, DatabaseIdNotAllowedForOwnDatabase, DatabaseIdInvalidLength, + ShareTokenInvalid, + ShareTokenExpired, + ShareTokenNotAllowed, + ShareTokenNotAllowedForOwnDatabase, ReadOnlyMustBeBoolean, ReadOnlyParamNotAllowed, ResharingAllowedMustBeBoolean, @@ -746,6 +800,7 @@ export default { ModifyingPermissionsNotAllowed, GrantingWriteAccessNotAllowed, RequireVerifiedMustBeBoolean, + RequireVerifiedParamNotNecessary, RevokeMustBeBoolean, ChangeHandlerMissing, ChangeHandlerMustBeFunction, diff --git a/src/userbase-js/src/ws.js b/src/userbase-js/src/ws.js index d3dd614d..baf6a13b 100644 --- a/src/userbase-js/src/ws.js +++ b/src/userbase-js/src/ws.js @@ -196,9 +196,12 @@ class Connection { }) } - const openingDatabase = message.dbNameHash && (message.dbKey || message.plaintextDbKey) - if (openingDatabase) { - const dbKeyString = message.plaintextDbKey || await crypto.aesGcm.decryptString(this.keys.encryptionKey, message.dbKey) + const openingDatabase = (message.dbNameHash && (message.dbKey || message.plaintextDbKey)) || message.shareTokenEncryptedDbKey + if (openingDatabase && (!database.dbKeyString || !database.dbKey)) { + const dbKeyString = message.plaintextDbKey || (message.dbKey + ? await crypto.aesGcm.decryptString(this.keys.encryptionKey, message.dbKey) + : await database.decryptShareTokenEncryptedDbKey(message.shareTokenEncryptedDbKey, message.shareTokenEncryptionKeySalt) + ) database.dbKeyString = dbKeyString database.dbKey = await crypto.aesGcm.getKeyFromKeyString(dbKeyString) } @@ -275,6 +278,8 @@ class Connection { case 'ResumeSubscription': case 'UpdatePaymentMethod': case 'ShareDatabase': + case 'ShareDatabaseToken': + case 'AuthenticateShareToken': case 'SaveDatabase': case 'ModifyDatabasePermissions': case 'VerifyUser': @@ -405,8 +410,15 @@ class Connection { const database = this.state.databasesByDbId[databaseId] if (!database.init) { + const shareTokenHkdfKey = database.shareTokenHkdfKey + + // if opened with shareToken, need to reauthenticate it + const shareTokenAuthData = shareTokenHkdfKey + ? await this.authenticateShareToken(databaseId, shareTokenHkdfKey) + : {} + const action = 'OpenDatabaseByDatabaseId' - const params = { databaseId, reopenAtSeqNo: database.lastSeqNo } + const params = { databaseId, reopenAtSeqNo: database.lastSeqNo, ...shareTokenAuthData } openDatabasePromises.push(this.request(action, params)) } } @@ -676,6 +688,30 @@ class Connection { this.keys.init = true } + + async authenticateShareToken(databaseId, shareTokenHkdfKey) { + // retrieve shareToken auth key data in order to prove access to shareToken to server + const action = 'AuthenticateShareToken' + const params = { databaseId } + const response = await this.request(action, params) + const { shareTokenAuthKeyData, validationMessage } = response.data + + // decrypt ECDSA private key. if it fails, not using the correct shareToken + let shareTokenEcdsaPrivateKey + try { + const shareTokenEcdsaKeyEncryptionKeySalt = base64.decode(shareTokenAuthKeyData.shareTokenEcdsaKeyEncryptionKeySalt) + const shareTokenEcdsaKeyEncryptionKey = await crypto.ecdsa.importEcdsaKeyEncryptionKeyFromMaster(shareTokenHkdfKey, shareTokenEcdsaKeyEncryptionKeySalt) + const shareTokenEncryptedEcdsaPrivateKey = base64.decode(shareTokenAuthKeyData.shareTokenEncryptedEcdsaPrivateKey) + const shareTokenEcdsaPrivateKeyRaw = await crypto.aesGcm.decrypt(shareTokenEcdsaKeyEncryptionKey, shareTokenEncryptedEcdsaPrivateKey) + shareTokenEcdsaPrivateKey = await crypto.ecdsa.getPrivateKeyFromRawPrivateKey(shareTokenEcdsaPrivateKeyRaw) + } catch { + throw new errors.ShareTokenExpired + } + + // sign validation message sent by the server + const signedValidationMessage = await crypto.ecdsa.sign(shareTokenEcdsaPrivateKey, base64.decode(validationMessage)) + return { validationMessage, signedValidationMessage: base64.encode(signedValidationMessage) } + } } export default new Connection() diff --git a/src/userbase-server/db.js b/src/userbase-server/db.js index d166a93e..e649a920 100644 --- a/src/userbase-server/db.js +++ b/src/userbase-server/db.js @@ -26,6 +26,8 @@ const HOURS_IN_A_DAY = 24 const SECONDS_IN_A_DAY = 60 * 60 * HOURS_IN_A_DAY const MS_IN_A_DAY = SECONDS_IN_A_DAY * 1000 +const VALIDATION_MESSAGE_LENGTH = 16 + const getS3DbStateKey = (databaseId, bundleSeqNo) => `${databaseId}/${bundleSeqNo}` const getS3DbWritersKey = (databaseId, bundleSeqNo) => `${databaseId}/writers/${bundleSeqNo}` const getS3FileChunkKey = (databaseId, fileId, chunkNumber) => `${databaseId}/${fileId}/${chunkNumber}` @@ -215,14 +217,14 @@ exports.openDatabase = async function (user, app, admin, connectionId, dbNameHas } if (!database) return responseBuilder.errorResponse(statusCodes['Not Found'], 'Database not found') - const dbId = database['database-id'] + const databaseId = database['database-id'] const bundleSeqNo = database['bundle-seq-no'] const dbKey = database['encrypted-db-key'] const attribution = database['attribution'] const plaintextDbKey = database['plaintext-db-key'] const isOwner = true - if (connections.openDatabase(userId, connectionId, dbId, bundleSeqNo, dbNameHash, dbKey, reopenAtSeqNo, isOwner, attribution, plaintextDbKey)) { + if (connections.openDatabase({ userId, connectionId, databaseId, bundleSeqNo, dbNameHash, dbKey, reopenAtSeqNo, isOwner, attribution, plaintextDbKey })) { return responseBuilder.successResponse('Success!') } else { throw new Error('Unable to open database') @@ -233,7 +235,20 @@ exports.openDatabase = async function (user, app, admin, connectionId, dbNameHas } } -exports.openDatabaseByDatabaseId = async function (userAtSignIn, app, admin, connectionId, databaseId, reopenAtSeqNo) { +const _validateAuthTokenSignature = (userId, database, validationMessage, signedValidationMessage) => { + if (!connections.isShareTokenValidationMessageCached(userId, validationMessage)) throw { + status: statusCodes['Unauthorized'], + error: 'RequestExpired' + } + + const shareTokenPublicKey = database['share-token-public-key'] + if (!crypto.ecdsa.verify(Buffer.from(validationMessage, 'base64'), shareTokenPublicKey, signedValidationMessage)) throw { + status: statusCodes['Unauthorized'], + error: 'ShareTokenExpired' + } +} + +exports.openDatabaseByDatabaseId = async function (userAtSignIn, app, admin, connectionId, databaseId, validationMessage, signedValidationMessage, reopenAtSeqNo) { let userId try { if (!databaseId) throw { status: statusCodes['Bad Request'], error: 'Missing database ID' } @@ -248,28 +263,37 @@ exports.openDatabaseByDatabaseId = async function (userAtSignIn, app, admin, con userController.getUserByUserId(userId), ]) - if (!db || !userDb || !user) throw { status: statusCodes['Not Found'], error: 'Database not found' } + if (!db || !user || (!userDb && !validationMessage)) throw { status: statusCodes['Not Found'], error: 'Database not found' } // Not allowing developers to use databaseId's to interact with databases owned by the user keeps the current concurrency model safe. const isOwner = db['owner-id'] === userId if (isOwner) throw { status: statusCodes['Forbidden'], error: 'Database is owned by user' } - const database = { ...db, ...userDb } - const dbNameHash = database['database-name-hash'] - const bundleSeqNo = database['bundle-seq-no'] - const dbKey = database['encrypted-db-key'] - const attribution = database['attribution'] - const plaintextDbKey = database['plaintext-db-key'] + const bundleSeqNo = db['bundle-seq-no'] + const attribution = db['attribution'] + const plaintextDbKey = db['plaintext-db-key'] + const connectionParams = { userId, connectionId, databaseId, bundleSeqNo, reopenAtSeqNo, isOwner, attribution, plaintextDbKey } + if (validationMessage) { + _validateAuthTokenSignature(userId, db, validationMessage, signedValidationMessage) + + connectionParams.shareTokenEncryptedDbKey = db['share-token-encrypted-db-key'] + connectionParams.shareTokenEncryptionKeySalt = db['share-token-encryption-key-salt'] + } else { + connectionParams.dbNameHash = userDb['database-name-hash'] + const dbKey = userDb['encrypted-db-key'] - // user must call getDatabases() first to set the db key - if (!dbKey && !plaintextDbKey) throw { status: statusCodes['Not Found'], error: 'Database key not found' } + // user must call getDatabases() first to set the db key + if (!dbKey && !plaintextDbKey) throw { status: statusCodes['Not Found'], error: 'Database key not found' } + connectionParams.dbKey = dbKey + connectionParams.plaintextDbKey = plaintextDbKey - // user must have the correct public key saved to access database - if (!plaintextDbKey && database['recipient-ecdsa-public-key'] !== user['ecdsa-public-key']) throw { - status: statusCodes['Not Found'], error: 'Database not found' + // user must have the correct public key saved to access database + if (!plaintextDbKey && userDb['recipient-ecdsa-public-key'] !== user['ecdsa-public-key']) throw { + status: statusCodes['Not Found'], error: 'Database not found' + } } - if (connections.openDatabase(userId, connectionId, databaseId, bundleSeqNo, dbNameHash, dbKey, reopenAtSeqNo, isOwner, attribution, plaintextDbKey)) { + if (connections.openDatabase(connectionParams)) { return responseBuilder.successResponse('Success!') } else { throw new Error('Unable to open database') @@ -622,13 +646,22 @@ const _incrementSeqNo = async function (transaction, databaseId) { } } -const putTransaction = async function (transaction, userId, databaseId) { +const putTransaction = async function (transaction, userId, connectionId, databaseId) { + if (!connections.isDatabaseOpen(userId, connectionId, databaseId)) throw { + status: statusCodes['Bad Request'], + error: { name: 'DatabaseNotOpen' } + } + + const openedWithShareToken = connections.isDatabaseOpenWithShareToken(userId, connectionId, databaseId) + // can be determined now, but not needed until later const userPromise = userController.getUserByUserId(userId) - // make both requests async to keep the time for successful putTransaction low - const [userDb] = await Promise.all([ - _getUserDatabaseByUserIdAndDatabaseId(userId, databaseId), + // incrementeSeqNo is only thing that needs to be done here, but making requests async to keep the + // time for successful putTransaction low + const [userDb, db] = await Promise.all([ + !openedWithShareToken && _getUserDatabaseByUserIdAndDatabaseId(userId, databaseId), + openedWithShareToken && findDatabaseByDatabaseId(databaseId), _incrementSeqNo(transaction, databaseId) ]) @@ -637,12 +670,12 @@ const putTransaction = async function (transaction, userId, databaseId) { transaction['user-id'] = userId try { - if (!userDb) { + if (!userDb && !db) { throw { status: statusCodes['Not Found'], error: { name: 'DatabaseNotFound' } } - } else if (userDb['read-only']) { + } else if (openedWithShareToken ? db['share-token-read-only'] : userDb['read-only']) { throw { status: statusCodes['Forbidden'], error: { name: 'DatabaseIsReadOnly' } @@ -674,10 +707,10 @@ const putTransaction = async function (transaction, userId, databaseId) { transaction['username'] = (await userPromise).username // notify all websocket connections that there's a database change - connections.push(transaction, userId) + connections.push(transaction) // broadcast transaction to all peers so they also push to their connected clients - peers.broadcastTransaction(transaction, userId) + peers.broadcastTransaction(transaction) return transaction['sequence-no'] } @@ -708,10 +741,6 @@ const doCommand = async function (command, userId, connectionId, databaseId, key if (!databaseId) return responseBuilder.errorResponse(statusCodes['Bad Request'], 'Missing database id') if (!key) return responseBuilder.errorResponse(statusCodes['Bad Request'], 'Missing item key') - if (!connections.isDatabaseOpen(userId, connectionId, databaseId)) { - return responseBuilder.errorResponse(statusCodes['Bad Request'], 'Database not open') - } - const transaction = { 'database-id': databaseId, key, @@ -737,7 +766,7 @@ const doCommand = async function (command, userId, connectionId, databaseId, key } try { - const sequenceNo = await putTransaction(transaction, userId, databaseId) + const sequenceNo = await putTransaction(transaction, userId, connectionId, databaseId) return responseBuilder.successResponse({ sequenceNo }) } catch (e) { const message = `Failed to ${command}` @@ -764,10 +793,6 @@ exports.batchTransaction = async function (userId, connectionId, databaseId, ope limit: MAX_OPERATIONS_IN_TX }) - if (!connections.isDatabaseOpen(userId, connectionId, databaseId)) { - return responseBuilder.errorResponse(statusCodes['Bad Request'], 'Database not open') - } - const ops = [] for (let i = 0; i < operations.length; i++) { const operation = operations[i] @@ -795,7 +820,7 @@ exports.batchTransaction = async function (userId, connectionId, databaseId, ope operations: ops } - const sequenceNo = await putTransaction(transaction, userId, databaseId) + const sequenceNo = await putTransaction(transaction, userId, connectionId, databaseId) return responseBuilder.successResponse({ sequenceNo }) } catch (e) { const message = 'Failed to batch transaction' @@ -943,8 +968,14 @@ exports.generateFileId = async function (logChildObject, userId, connectionId, d return responseBuilder.errorResponse(statusCodes['Bad Request'], 'Database not open') } - const userDb = await _getUserDatabaseByUserIdAndDatabaseId(userId, databaseId) - if (userDb['read-only']) { + const openedWithShareToken = connections.isDatabaseOpenWithShareToken(userId, connectionId, databaseId) + + const [userDb, db] = await Promise.all([ + !openedWithShareToken && _getUserDatabaseByUserIdAndDatabaseId(userId, databaseId), + openedWithShareToken && findDatabaseByDatabaseId(databaseId), + ]) + + if (!openedWithShareToken ? userDb['read-only'] : db['share-token-read-only']) { throw { status: statusCodes['Forbidden'], error: { message: 'DatabaseIsReadOnly' } @@ -1118,6 +1149,23 @@ const _validateShareDatabase = async function (sender, dbId, dbNameHash, recipie return { database, senderUserDb, recipient } } +const _validateShareDatabaseToken = async function (sender, dbId, dbNameHash) { + const [database, senderUserDb] = await Promise.all([ + findDatabaseByDatabaseId(dbId), + _getUserDatabase(sender['user-id'], dbNameHash), + ]) + + if (!database || !senderUserDb || senderUserDb['database-id'] !== dbId) throw { + status: statusCodes['Not Found'], + error: { message: 'DatabaseNotFound' } + } + + if (database['owner-id'] !== sender['user-id']) throw { + status: statusCodes['Forbidden'], + error: { message: 'ResharingNotAllowed' } + } +} + const _buildSharedUserDatabaseParams = (userId, dbId, readOnly, resharingAllowed, senderId, sharedEncryptedDbKey, wrappedDbKey, ephemeralPublicKey, signedEphemeralPublicKey, ecdsaPublicKey, sentSignature, recipientEcdsaPublicKey) => { // user will only be able to open the database using database ID. Only requirement is that this value is unique @@ -1202,6 +1250,106 @@ exports.shareDatabase = async function (logChildObject, sender, dbId, dbNameHash } } +exports.shareDatabaseToken = async function (logChildObject, sender, dbId, dbNameHash, readOnly, keyData) { + try { + if (typeof readOnly !== 'boolean') throw { + status: statusCodes['Bad Request'], + error: { message: 'ReadOnlyMustBeBoolean' } + } + + await _validateShareDatabaseToken(sender, dbId, dbNameHash) + + const { + shareTokenEncryptedDbKey, + shareTokenEncryptionKeySalt, + shareTokenPublicKey, + shareTokenEncryptedEcdsaPrivateKey, + shareTokenEcdsaKeyEncryptionKeySalt, + } = keyData + + const params = { + TableName: setup.databaseTableName, + Key: { + 'database-id': dbId, + }, + UpdateExpression: `SET + #shareTokenEncryptedDbKey = :shareTokenEncryptedDbKey, + #shareTokenEncryptionKeySalt = :shareTokenEncryptionKeySalt, + #shareTokenPublicKey = :shareTokenPublicKey, + #shareTokenEncryptedEcdsaPrivateKey = :shareTokenEncryptedEcdsaPrivateKey, + #shareTokenEcdsaKeyEncryptionKeySalt = :shareTokenEcdsaKeyEncryptionKeySalt, + #shareTokenReadOnly = :shareTokenReadOnly + `, + ExpressionAttributeNames: { + '#shareTokenEncryptedDbKey': 'share-token-encrypted-db-key', + '#shareTokenEncryptionKeySalt': 'share-token-encryption-key-salt', + '#shareTokenPublicKey': 'share-token-public-key', + '#shareTokenEncryptedEcdsaPrivateKey': 'share-token-encrypted-ecdsa-private-key', + '#shareTokenEcdsaKeyEncryptionKeySalt': 'share-token-ecdsa-key-encryption-key-salt', + '#shareTokenReadOnly': 'share-token-read-only', + }, + ExpressionAttributeValues: { + ':shareTokenEncryptedDbKey': shareTokenEncryptedDbKey, + ':shareTokenEncryptionKeySalt': shareTokenEncryptionKeySalt, + ':shareTokenPublicKey': shareTokenPublicKey, + ':shareTokenEncryptedEcdsaPrivateKey': shareTokenEncryptedEcdsaPrivateKey, + ':shareTokenEcdsaKeyEncryptionKeySalt': shareTokenEcdsaKeyEncryptionKeySalt, + ':shareTokenReadOnly': readOnly + } + } + + const ddbClient = connection.ddbClient() + await ddbClient.update(params).promise() + + return responseBuilder.successResponse('Success!') + } catch (e) { + logChildObject.err = e + + if (e.status && e.error) { + return responseBuilder.errorResponse(e.status, e.error) + } else { + return responseBuilder.errorResponse(statusCodes['Internal Server Error'], 'Failed to share database token') + } + } +} + +exports.authenticateShareToken = async function (logChildObject, user, dbId) { + try { + const database = await findDatabaseByDatabaseId(dbId) + + if (!database) throw { + status: statusCodes['Not Found'], + error: { message: 'DatabaseNotFound' } + } + + const shareTokenAuthKeyData = { + shareTokenEncryptedEcdsaPrivateKey: database['share-token-encrypted-ecdsa-private-key'], + shareTokenEcdsaKeyEncryptionKeySalt: database['share-token-ecdsa-key-encryption-key-salt'], + } + + if (!shareTokenAuthKeyData.shareTokenEncryptedEcdsaPrivateKey) throw { + status: statusCodes['Not Found'], + error: { message: 'ShareTokenNotFound' } + } + + // user must sign this message to open the database with share token + const validationMessage = crypto.randomBytes(VALIDATION_MESSAGE_LENGTH).toString('base64') + + // cache the validation message so server can validate access to share token on open + connections.cacheShareTokenValidationMessage(user['user-id'], validationMessage) + + return responseBuilder.successResponse({ shareTokenAuthKeyData, validationMessage }) + } catch (e) { + logChildObject.err = e + + if (e.status && e.error) { + return responseBuilder.errorResponse(e.status, e.error) + } else { + return responseBuilder.errorResponse(statusCodes['Internal Server Error'], 'Failed to get database share token auth data') + } + } +} + exports.saveDatabase = async function (logChildObject, user, dbNameHash, encryptedDbKey, receivedSignature) { try { const params = { diff --git a/src/userbase-server/server.js b/src/userbase-server/server.js index a10115aa..af375ed6 100755 --- a/src/userbase-server/server.js +++ b/src/userbase-server/server.js @@ -217,7 +217,9 @@ async function start(express, app, userbaseConfig = {}) { res.locals.admin, connectionId, params.databaseId, - params.reopenAtSeqNo + params.validationMessage, + params.signedValidationMessage, + params.reopenAtSeqNo, ) break } @@ -386,6 +388,21 @@ async function start(express, app, userbaseConfig = {}) { ) break } + case 'ShareDatabaseToken': { + response = await db.shareDatabaseToken( + logChildObject, + res.locals.user, + params.databaseId, + params.databaseNameHash, + params.readOnly, + params.keyData + ) + break + } + case 'AuthenticateShareToken': { + response = await db.authenticateShareToken(logChildObject, res.locals.user, params.databaseId) + break + } case 'SaveDatabase': { response = await db.saveDatabase( logChildObject, diff --git a/src/userbase-server/ws.js b/src/userbase-server/ws.js index c3a00587..b45fb041 100644 --- a/src/userbase-server/ws.js +++ b/src/userbase-server/ws.js @@ -10,7 +10,7 @@ import { getUserByUserId } from './user' const SECONDS_BEFORE_ROLLBACK_GAP_TRIGGERED = 1000 * 10 // 10s const TRANSACTION_SIZE_BUNDLE_TRIGGER = 1024 * 50 // 50 KB -const FILE_ID_CACHE_LIFE = 60 * 1000 // 60s +const CACHE_LIFE = 60 * 1000 // 60s const DATA_STORAGE_MAX_REQUESTS_PER_SECOND = 200 const DATA_STORAGE_TOKENS_REFILLED_PER_SECOND = 20 @@ -65,7 +65,7 @@ class Connection { this.fileStorageRateLimiter = new TokenBucket(FILE_STORAGE_MAX_REQUESTS_PER_SECOND, FILE_STORAGE_TOKENS_REFILLED_PER_SECOND) } - openDatabase(databaseId, dbNameHash, bundleSeqNo, reopenAtSeqNo, isOwner, attribution) { + openDatabase(databaseId, dbNameHash, bundleSeqNo, reopenAtSeqNo, isOwner, attribution, usedShareToken) { this.databases[databaseId] = { bundleSeqNo: bundleSeqNo > 0 ? bundleSeqNo : -1, lastSeqNo: reopenAtSeqNo || 0, @@ -74,6 +74,7 @@ class Connection { dbNameHash, isOwner, attribution, + usedShareToken, } } @@ -81,7 +82,7 @@ class Connection { this.keyValidated = true } - async push(databaseId, dbNameHash, dbKey, reopenAtSeqNo, plaintextDbKey) { + async push(databaseId, dbNameHash, dbKey, reopenAtSeqNo, plaintextDbKey, shareTokenEncryptedDbKey, shareTokenEncryptionKeySalt) { const database = this.databases[databaseId] if (!database) return @@ -98,11 +99,14 @@ class Connection { const reopeningDatabase = reopenAtSeqNo !== undefined - const openingDatabase = dbNameHash && !reopeningDatabase && (dbKey || plaintextDbKey) + // opening databse by name, by databaseId, or by shareToken + const openingDatabase = (dbNameHash || shareTokenEncryptedDbKey) && !reopeningDatabase && (dbKey || plaintextDbKey || shareTokenEncryptedDbKey) if (openingDatabase) { payload.dbNameHash = dbNameHash payload.dbKey = dbKey payload.plaintextDbKey = plaintextDbKey + payload.shareTokenEncryptedDbKey = shareTokenEncryptedDbKey + payload.shareTokenEncryptionKeySalt = shareTokenEncryptionKeySalt } let lastSeqNo = database.lastSeqNo @@ -369,7 +373,7 @@ class Connection { export default class Connections { static register(userId, socket, clientId, adminId, appId) { if (!Connections.sockets) Connections.sockets = {} - if (!Connections.sockets[userId]) Connections.sockets[userId] = { numConnections: 0, fileIds: {} } + if (!Connections.sockets[userId]) Connections.sockets[userId] = { numConnections: 0, fileIds: {}, shareTokenValidationMessages: {} } if (!Connections.sockets[adminId]) Connections.sockets[adminId] = { numConnections: 0 } if (!Connections.sockets[appId]) Connections.sockets[appId] = { numConnections: 0 } @@ -398,21 +402,24 @@ export default class Connections { return connection } - static openDatabase(userId, connectionId, databaseId, bundleSeqNo, dbNameHash, dbKey, reopenAtSeqNo, isOwner, attribution, plaintextDbKey) { + static openDatabase({ userId, connectionId, databaseId, bundleSeqNo, dbNameHash, dbKey, reopenAtSeqNo, isOwner, + attribution, plaintextDbKey, shareTokenEncryptedDbKey, shareTokenEncryptionKeySalt }) { if (!Connections.sockets || !Connections.sockets[userId] || !Connections.sockets[userId][connectionId]) return const conn = Connections.sockets[userId][connectionId] if (!conn.databases[databaseId]) { - conn.openDatabase(databaseId, dbNameHash, bundleSeqNo, reopenAtSeqNo, isOwner, attribution) - logger.child({ connectionId, databaseId, adminId: conn.adminId, encryptionMode: plaintextDbKey ? 'server-side' : 'end-to-end' }).info('Database opened') - } + const usedShareToken = shareTokenEncryptedDbKey ? true : false + conn.openDatabase(databaseId, dbNameHash, bundleSeqNo, reopenAtSeqNo, isOwner, attribution, usedShareToken) + + if (!Connections.sockets[databaseId]) Connections.sockets[databaseId] = { numConnections: 0 } + Connections.sockets[databaseId][connectionId] = userId + Connections.sockets[databaseId].numConnections += 1 - conn.push(databaseId, dbNameHash, dbKey, reopenAtSeqNo, plaintextDbKey) + logger.child({ connectionId, databaseId, adminId: conn.adminId, encryptionMode: plaintextDbKey ? 'server-side' : 'end-to-end', usedShareToken }).info('Database opened') + } - if (!Connections.sockets[databaseId]) Connections.sockets[databaseId] = { numConnections: 0 } - Connections.sockets[databaseId][connectionId] = userId - Connections.sockets[databaseId].numConnections += 1 + conn.push(databaseId, dbNameHash, dbKey, reopenAtSeqNo, plaintextDbKey, shareTokenEncryptedDbKey, shareTokenEncryptionKeySalt) return true } @@ -425,6 +432,14 @@ export default class Connections { return conn.databases[databaseId] ? true : false } + static isDatabaseOpenWithShareToken(userId, connectionId, databaseId) { + if (!Connections.sockets || !Connections.sockets[userId] || !Connections.sockets[userId][connectionId]) return + + const conn = Connections.sockets[userId][connectionId] + + return (conn.databases[databaseId] && conn.databases[databaseId].usedShareToken) ? true : false + } + static push(transaction) { const dbId = transaction['database-id'] if (!Connections.sockets || !Connections.sockets[dbId]) return @@ -452,7 +467,7 @@ export default class Connections { } // requery DDB anyway in case there are any lingering transactions that need to get pushed out - conn.push(transaction['database-id']) + conn.push(dbId) } } } @@ -531,13 +546,13 @@ export default class Connections { static cacheFileId(userId, fileId) { if (!Connections.sockets || !Connections.sockets[userId] || !Connections.sockets[userId].fileIds) return - // after FILE_ID_CACHE_LIFE seconds, delete the fileId from the cache + // after CACHE_LIFE seconds, delete the fileId from the cache Connections.sockets[userId].fileIds[fileId] = setTimeout(() => { if (Connections.sockets && Connections.sockets[userId] && Connections.sockets[userId].fileIds) { delete Connections.sockets[userId].fileIds[fileId] } }, - FILE_ID_CACHE_LIFE + CACHE_LIFE ) } @@ -549,4 +564,23 @@ export default class Connections { this.cacheFileId(userId, fileId) return true } + + static cacheShareTokenValidationMessage(userId, validationMessage) { + if (!Connections.sockets || !Connections.sockets[userId] || !Connections.sockets[userId].shareTokenValidationMessages) return + + // after CACHE_LIFE seconds, delete the validationMessage from the cache + Connections.sockets[userId].shareTokenValidationMessages[validationMessage] = setTimeout(() => { + if (Connections.sockets && Connections.sockets[userId] && Connections.sockets[userId].shareTokenValidationMessages) { + delete Connections.sockets[userId].shareTokenValidationMessages[validationMessage] + } + }, + CACHE_LIFE + ) + } + + static isShareTokenValidationMessageCached(userId, validationMessage) { + if (!Connections.sockets || !Connections.sockets[userId] || !Connections.sockets[userId].shareTokenValidationMessages || + !Connections.sockets[userId].shareTokenValidationMessages[validationMessage]) return false + return true + } } From 078ae3946bffa1a7f1aa2df6b826c467e1788eea Mon Sep 17 00:00:00 2001 From: Justin Berman Date: Sun, 13 Dec 2020 22:53:01 -0800 Subject: [PATCH 2/7] Testing share token + updated typescript --- .../Database Sharing/share-database.spec.js | 653 +++++++++++++++++- .../db.server-side-encryption.spec.js | 2 +- src/userbase-js/types/index.d.ts | 22 +- src/userbase-js/types/test.ts | 34 +- 4 files changed, 679 insertions(+), 32 deletions(-) diff --git a/cypress/integration/Database Sharing/share-database.spec.js b/cypress/integration/Database Sharing/share-database.spec.js index a3d74c95..28a5ac9a 100644 --- a/cypress/integration/Database Sharing/share-database.spec.js +++ b/cypress/integration/Database Sharing/share-database.spec.js @@ -29,7 +29,7 @@ const signUp = async (userbase) => { describe('DB Sharing Tests', function () { const databaseName = 'test-db' - describe('Share Database', function () { + describe('Share Database with username', function () { describe('Sucess Tests', function () { beforeEach(function () { beforeEachHook() }) @@ -724,7 +724,7 @@ describe('DB Sharing Tests', function () { } try { - await this.test.userbase.putTransaction({ databaseId, operations: [{ command: 'Insert', item: testItem, itemId: testItemId }] }) + await this.test.userbase.putTransaction({ databaseId, operations: [{ command: 'Delete', itemId: testItemId }] }) throw new Error('Should have failed') } catch (e) { expectedError(e) @@ -1110,22 +1110,6 @@ describe('DB Sharing Tests', function () { await this.test.userbase.deleteUser() }) - it('Username missing', async function () { - await signUp(this.test.userbase) - - try { - await this.test.userbase.shareDatabase({ databaseName }) - throw new Error('Should have failed') - } catch (e) { - expect(e.name, 'error name').to.be.equal('UsernameMissing') - expect(e.message, 'error message').to.be.equal('Username missing.') - expect(e.status, 'error status').to.be.equal(400) - } - - // clean up - await this.test.userbase.deleteUser() - }) - it('Username cannot be blank', async function () { await signUp(this.test.userbase) @@ -1221,4 +1205,637 @@ describe('DB Sharing Tests', function () { }) + describe('Share Database by retrieving share token', function () { + + describe('Sucess Tests', function () { + beforeEach(function () { beforeEachHook() }) + + it('Default', async function () { + const sender = await signUp(this.test.userbase) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // sender gets share token + const { shareToken } = await this.test.userbase.shareDatabase({ databaseName }) + + // sender inserts item into database + const testItem = 'hello world!' + const testItemId = 'test-id' + await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) + await this.test.userbase.signOut() + + // recipient signs up and checks if can read the database with shareToken + await signUp(this.test.userbase) + + let changeHandlerCallCount = 0 + const changeHandler = function (items) { + expect(items, 'array passed to changeHandler').to.be.a('array') + expect(items, 'array passed to changeHandler').to.deep.equal([{ + itemId: testItemId, + item: testItem, + createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp } + }]) + + changeHandlerCallCount += 1 + } + + await this.test.userbase.openDatabase({ shareToken, changeHandler }) + + expect(changeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) + + // clean up + await this.test.userbase.deleteUser() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + await this.test.userbase.deleteUser() + }) + + it('Share own database by databaseId', async function () { + const sender = await signUp(this.test.userbase) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // get database's id + const { databases: [{ databaseId }] } = await this.test.userbase.getDatabases() + + // sender gets share token + const { shareToken } = await this.test.userbase.shareDatabase({ databaseId }) + + // sender inserts item into database + const testItem = 'hello world!' + const testItemId = 'test-id' + await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) + await this.test.userbase.signOut() + + // recipient signs up and checks if can read the database with shareToken + await signUp(this.test.userbase) + + // getDatabases() must be run before opening a database by its databaseId + await this.test.userbase.getDatabases() + + let changeHandlerCallCount = 0 + const changeHandler = function (items) { + expect(items, 'array passed to changeHandler').to.be.a('array') + expect(items, 'array passed to changeHandler').to.deep.equal([{ + itemId: testItemId, + item: testItem, + createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp } + }]) + + changeHandlerCallCount += 1 + } + + await this.test.userbase.openDatabase({ shareToken, changeHandler }) + + expect(changeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) + + // clean up + await this.test.userbase.deleteUser() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + await this.test.userbase.deleteUser() + }) + + it('readOnly false', async function () { + const sender = await signUp(this.test.userbase) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // sender gets share token + const { shareToken } = await this.test.userbase.shareDatabase({ databaseName, readOnly: false }) + await this.test.userbase.signOut() + + // recipient signs up and checks if can insert into the database + const recipient = await signUp(this.test.userbase) + + let recipientChangeHandlerCallCount = 0 + const recipientChangeHandler = function (items) { + expect(items, 'array passed to changeHandler').to.be.a('array') + + if (recipientChangeHandlerCallCount === 0) { + expect(items, 'array passed to changeHandler').to.deep.equal([]) + } else { + expect(items, 'array passed to changeHandler').to.deep.equal([{ + itemId: testItemId, + item: testItem, + createdBy: { username: recipient.username, timestamp: items[0].createdBy.timestamp } + }]) + } + + recipientChangeHandlerCallCount += 1 + } + + await this.test.userbase.openDatabase({ shareToken, changeHandler: recipientChangeHandler }) + + // recipient inserts item into database + const testItem = 'hello world!' + const testItemId = 'test-id' + await this.test.userbase.insertItem({ shareToken, item: testItem, itemId: testItemId }) + + expect(recipientChangeHandlerCallCount, 'changeHandler called correct number of times').to.equal(2) + + await this.test.userbase.deleteUser() + + // sender should be able to read the item too + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + + let senderChangeHandlerCallCount = 0 + const senderChangeHandler = function (items) { + expect(items, 'array passed to changeHandler').to.be.a('array') + expect(items, 'array passed to changeHandler').to.deep.equal([{ + itemId: testItemId, + item: testItem, + createdBy: { userDeleted: true, timestamp: items[0].createdBy.timestamp } + }]) + + senderChangeHandlerCallCount += 1 + } + await this.test.userbase.openDatabase({ databaseName, changeHandler: senderChangeHandler }) + + expect(senderChangeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Calling twice overwrites share token', async function () { + const sender = await signUp(this.test.userbase) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // sender gets 2 share tokens, only 2nd works + const firstShareToken = await this.test.userbase.shareDatabase({ databaseName }) + const { shareToken } = await this.test.userbase.shareDatabase({ databaseName }) + expect(firstShareToken.shareToken, 'diff share tokens').to.not.eq(shareToken) + + // sender inserts item into database + const testItem = 'hello world!' + const testItemId = 'test-id' + await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) + await this.test.userbase.signOut() + + // recipient signs up and checks if can read the database + await signUp(this.test.userbase) + + let changeHandlerCallCount = 0 + const changeHandler = function (items) { + expect(items, 'array passed to changeHandler').to.be.a('array') + expect(items, 'array passed to changeHandler').to.deep.equal([{ + itemId: testItemId, + item: testItem, + createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp } + }]) + + changeHandlerCallCount += 1 + } + + await this.test.userbase.openDatabase({ shareToken, changeHandler }) + + expect(changeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) + + // first share token should not work + try { + await this.test.userbase.openDatabase({ shareToken: firstShareToken.shareToken, changeHandler }) + throw new Error('should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('ShareTokenExpired') + expect(e.message, 'error message').to.be.equal('Share token expired. The database owner has generated a new share token.') + expect(e.status, 'error status').to.be.equal(403) + } + + // clean up + await this.test.userbase.deleteUser() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + await this.test.userbase.deleteUser() + }) + }) + + describe('Failure Tests', function () { + beforeEach(function () { beforeEachHook() }) + + it('Database is read only', async function () { + const sender = await signUp(this.test.userbase) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + const testItem = 'hello world!' + const testItemId = 'test-id' + await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) + + // sender gets share token + const { shareToken } = await this.test.userbase.shareDatabase({ databaseName }) + await this.test.userbase.signOut() + + // recipient signs up and checks if can insert into the database + await signUp(this.test.userbase) + await this.test.userbase.openDatabase({ shareToken, changeHandler: () => { } }) + + const expectedError = (e) => { + expect(e.name, 'error name').to.be.equal('DatabaseIsReadOnly') + expect(e.message, 'error message').to.be.equal('Database is read only. Must have permission to write to database.') + expect(e.status, 'error status').to.be.equal(403) + } + + // recipient tries to insert, update, delete, putTransaction, uploadFile into database + try { + await this.test.userbase.insertItem({ shareToken, item: testItem }) + throw new Error('Should have failed') + } catch (e) { + expectedError(e) + } + + try { + await this.test.userbase.updateItem({ shareToken, item: testItem, itemId: testItemId }) + throw new Error('Should have failed') + } catch (e) { + expectedError(e) + } + + try { + await this.test.userbase.deleteItem({ shareToken, item: testItem, itemId: testItemId }) + throw new Error('Should have failed') + } catch (e) { + expectedError(e) + } + + try { + await this.test.userbase.putTransaction({ shareToken, operations: [{ command: 'Delete', itemId: testItemId }] }) + throw new Error('Should have failed') + } catch (e) { + expectedError(e) + } + + try { + const testFileName = 'test-file-name.txt' + const testFileType = 'text/plain' + const testFile = new this.test.win.File([1], testFileName, { type: testFileType }) + await this.test.userbase.uploadFile({ shareToken, file: testFile, itemId: testItemId }) + throw new Error('Should have failed') + } catch (e) { + expectedError(e) + } + + // clean up + await this.test.userbase.deleteUser() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + await this.test.userbase.deleteUser() + }) + + it('Resharing not allowed', async function () { + const recipient = await signUp(this.test.userbase) + const { verificationMessage } = await this.test.userbase.getVerificationMessage() + await this.test.userbase.signOut() + + const sender = await signUp(this.test.userbase) + await this.test.userbase.verifyUser({ verificationMessage }) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // sender shares database with recipient + await this.test.userbase.shareDatabase({ databaseName, username: recipient.username }) + await this.test.userbase.signOut() + + // recipient signs in and checks if can get a new share token + await this.test.userbase.signIn({ username: recipient.username, password: recipient.password, rememberMe: 'none' }) + + // recipient must find the database's databaseId using getDatabases() result + const { databases } = await this.test.userbase.getDatabases() + const db = databases[0] + const { databaseId } = db + + try { + await this.test.userbase.shareDatabase({ databaseId }) + throw new Error('should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('ResharingNotAllowed') + expect(e.message, 'error message').to.be.equal('Resharing not allowed. Only the owner can generate a share token.') + expect(e.status, 'error status').to.be.equal(403) + } + + // clean up + await this.test.userbase.deleteUser() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + await this.test.userbase.deleteUser() + }) + + it('Share token invalid', async function () { + await signUp(this.test.userbase) + + try { + await this.test.userbase.openDatabase({ shareToken: 'a', changeHandler: () => { } }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('ShareTokenInvalid') + expect(e.message, 'error message').to.be.equal('Share token invalid.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Share token not allowed - passing share token to shareDatabase', async function () { + const sender = await signUp(this.test.userbase) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // sender gets share token + const { shareToken } = await this.test.userbase.shareDatabase({ databaseName }) + await this.test.userbase.signOut() + + // recipient signs up and tries to reshare share token + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({ shareToken }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('ShareTokenNotAllowed') + expect(e.message, 'error message').to.be.equal('Share token not allowed.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + await this.test.userbase.deleteUser() + }) + + it('Share token not allowed - passing database name and share token to shareDatabase', async function () { + await signUp(this.test.userbase) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + try { + await this.test.userbase.shareDatabase({ databaseName, shareToken: '' }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('ShareTokenNotAllowed') + expect(e.message, 'error message').to.be.equal('Share token not allowed.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Share token not allowed - passing database ID and share token to shareDatabase', async function () { + await signUp(this.test.userbase) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // get database's id + const { databases: [{ databaseId }] } = await this.test.userbase.getDatabases() + + try { + await this.test.userbase.shareDatabase({ databaseId, shareToken: '' }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('ShareTokenNotAllowed') + expect(e.message, 'error message').to.be.equal('Share token not allowed.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Owner opening with share token not allowed', async function () { + const sender = await signUp(this.test.userbase) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // sender gets share token, then tries to open with it + const { shareToken } = await this.test.userbase.shareDatabase({ databaseName }) + await this.test.userbase.signOut() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + + // sender tries to share database with self + try { + await this.test.userbase.openDatabase({ shareToken, changeHandler: () => { } }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('ShareTokenNotAllowedForOwnDatabase') + expect(e.message, 'error message').to.be.equal("Tried to open the user's own database using its shareToken rather than its databaseName. The shareToken should only be used to open databases shared from other users.") + expect(e.status, 'error status').to.be.equal(403) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Database not found - does not exist', async function () { + // sign up sender + await signUp(this.test.userbase) + + // sender tries to share non-existent database + try { + await this.test.userbase.shareDatabase({ databaseName }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('DatabaseNotFound') + expect(e.message, 'error message').to.be.equal('Database not found. Find available databases using getDatabases().') + expect(e.status, 'error status').to.be.equal(404) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Database name missing', async function () { + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({}) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('DatabaseNameMissing') + expect(e.message, 'error message').to.be.equal('Database name missing.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Database name must be string', async function () { + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({ databaseName: 1 }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('DatabaseNameMustBeString') + expect(e.message, 'error message').to.be.equal('Database name must be a string.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Database name cannot be blank', async function () { + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({ databaseName: '' }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('DatabaseNameCannotBeBlank') + expect(e.message, 'error message').to.be.equal('Database name cannot be blank.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Database name too long', async function () { + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({ databaseName: 'a'.repeat(101) }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.equal('DatabaseNameTooLong') + expect(e.message, 'error message').to.equal('Database name cannot be more than 100 characters.') + expect(e.status, 'error status').to.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Database name restricted', async function () { + const verifiedUsersDatabaseName = '__userbase_verified_users' + + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({ databaseName: verifiedUsersDatabaseName }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.equal('DatabaseNameRestricted') + expect(e.message, 'error message').to.equal(`Database name '${verifiedUsersDatabaseName}' is restricted. It is used internally by userbase-js.`) + expect(e.status, 'error status').to.equal(403) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Database id not allowed', async function () { + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({ databaseId: 'abc', databaseName }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('DatabaseIdNotAllowed') + expect(e.message, 'error message').to.be.equal('Database id not allowed. Cannot provide both databaseName and databaseId, can only provide one.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Database id must be string', async function () { + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({ databaseId: 1 }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.equal('DatabaseIdMustBeString') + expect(e.message, 'error message').to.equal('Database id must be a string.') + expect(e.status, 'error status').to.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Database id cannot be blank', async function () { + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({ databaseId: '' }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('DatabaseIdCannotBeBlank') + expect(e.message, 'error message').to.be.equal('Database id cannot be blank.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Database id invalid length', async function () { + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({ databaseId: 'abc' }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('DatabaseIdInvalidLength') + expect(e.message, 'error message').to.be.equal('Database id invalid length. Must be 36 characters.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Read Only must be boolean', async function () { + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({ databaseName, readOnly: 'not boolean' }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('ReadOnlyMustBeBoolean') + expect(e.message, 'error message').to.be.equal('Read only value must be a boolean.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Resharing allowed param not allowed', async function () { + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({ databaseName, resharingAllowed: true }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('ResharingAllowedParamNotAllowed') + expect(e.message, 'error message').to.be.equal('Resharing allowed parameter not allowed when retrieving a share token.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Require verified param not necessary', async function () { + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({ databaseName, requireVerified: true }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('RequireVerifiedParamNotNecessary') + expect(e.message, 'error message').to.be.equal('Require verified parameter not necessary when sharing database without a username.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('User not signed in', async function () { + try { + await this.test.userbase.shareDatabase({ databaseName }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('UserNotSignedIn') + expect(e.message, 'error message').to.be.equal('Not signed in.') + expect(e.status, 'error status').to.be.equal(400) + } + }) + + }) + + }) }) diff --git a/cypress/integration/db.server-side-encryption.spec.js b/cypress/integration/db.server-side-encryption.spec.js index c071741e..4e82c240 100644 --- a/cypress/integration/db.server-side-encryption.spec.js +++ b/cypress/integration/db.server-side-encryption.spec.js @@ -103,7 +103,7 @@ const successfulUpdateSingleItem = async function (itemToUpdate, userbase, insid expect(successful, 'successful state').to.be.true } -describe('DB Tests', function () { +describe('DB Tests - server-side encryption', function () { describe('Insert Item', function () { diff --git a/src/userbase-js/types/index.d.ts b/src/userbase-js/types/index.d.ts index 6513384f..8755722b 100644 --- a/src/userbase-js/types/index.d.ts +++ b/src/userbase-js/types/index.d.ts @@ -111,6 +111,12 @@ export interface CancelSubscriptionResult { export type databaseNameXorId = ({ databaseId: string, databaseName?: never } | { databaseName: string, databaseId?: never }); +export type databaseNameXorIdXorShareToken = ( + { databaseId: string, databaseName?: never, shareToken?: never } | + { shareToken: string, databaseName?: never, databaseId?: never } | + { databaseName: string, databaseId?: never, shareToken?: never } +) + export type priceIdXorPlanId = ({ priceId?: string, planId?: never } | { planId?: string, priceId?: never }); export interface Userbase { @@ -128,27 +134,27 @@ export interface Userbase { forgotPassword(params: { username: string }): Promise - openDatabase(params: databaseNameXorId & { changeHandler: DatabaseChangeHandler }): Promise + openDatabase(params: databaseNameXorIdXorShareToken & { changeHandler: DatabaseChangeHandler }): Promise getDatabases(params?: databaseNameXorId): Promise - insertItem(params: databaseNameXorId & { item: any, itemId?: string }): Promise + insertItem(params: databaseNameXorIdXorShareToken & { item: any, itemId?: string }): Promise - updateItem(params: databaseNameXorId & { item: any, itemId: string }): Promise + updateItem(params: databaseNameXorIdXorShareToken & { item: any, itemId: string }): Promise - deleteItem(params: databaseNameXorId & { itemId: string }): Promise + deleteItem(params: databaseNameXorIdXorShareToken & { itemId: string }): Promise - putTransaction(params: databaseNameXorId & { operations: DatabaseOperation[] }): Promise + putTransaction(params: databaseNameXorIdXorShareToken & { operations: DatabaseOperation[] }): Promise - uploadFile(params: databaseNameXorId & { itemId: string, file: File, progressHandler?: FileUploadProgressHandler }): Promise + uploadFile(params: databaseNameXorIdXorShareToken & { itemId: string, file: File, progressHandler?: FileUploadProgressHandler }): Promise - getFile(params: databaseNameXorId & { fileId: string, range?: { start: number, end: number } }): Promise + getFile(params: databaseNameXorIdXorShareToken & { fileId: string, range?: { start: number, end: number } }): Promise getVerificationMessage(): Promise<{ verificationMessage: string }> verifyUser(params: { verificationMessage: string }): Promise - shareDatabase(params: databaseNameXorId & { username: string, requireVerified?: boolean, readOnly?: boolean, resharingAllowed?: boolean }): Promise + shareDatabase(params: databaseNameXorId & { username?: string, requireVerified?: boolean, readOnly?: boolean, resharingAllowed?: boolean }): Promise<{ shareToken?: string }> modifyDatabasePermissions(params: databaseNameXorId & { username: string, readOnly?: boolean, resharingAllowed?: boolean, revoke?: boolean }): Promise diff --git a/src/userbase-js/types/test.ts b/src/userbase-js/types/test.ts index 31f4d911..21269671 100644 --- a/src/userbase-js/types/test.ts +++ b/src/userbase-js/types/test.ts @@ -130,12 +130,30 @@ forgotPassword({}) // $ExpectType Promise openDatabase({ databaseName: 'tdb', changeHandler: (items) => { } }) +// $ExpectType Promise +openDatabase({ databaseId: 'tid', changeHandler: (items) => { } }) + +// $ExpectType Promise +openDatabase({ shareToken: 'tst', changeHandler: (items) => { } }) + // $ExpectError openDatabase({ databaseName: 'tdb' }) // $ExpectError openDatabase({}) +// $ExpectError +openDatabase({ databaseName: 'tdb', databaseId: 'tid', changeHandler: (items) => { } }) + +// $ExpectError +openDatabase({ databaseId: 'tid', shareToken: 'tst', changeHandler: (items) => { } }) + +// $ExpectError +openDatabase({ databaseName: 'tdb', shareToken: 'st', changeHandler: (items) => { } }) + +// $ExpectError +openDatabase({ databaseName: 'tdb', databaseId: 'tid', shareToken: 'st', changeHandler: (items) => { } }) + // $ExpectType Promise getDatabases() @@ -254,20 +272,26 @@ verifyUser({ verificationMessage: 'tvf' }) // $ExpectError verifyUser() -// $ExpectType Promise +// $ExpectType Promise<{ shareToken?: string | undefined; }> shareDatabase({ databaseName: 'tdb', username: 'tuser' }) -// $ExpectType Promise +// $ExpectType Promise<{ shareToken?: string | undefined; }> shareDatabase({ databaseId: 'tid', username: 'tuser' }) -// $ExpectType Promise +// $ExpectType Promise<{ shareToken?: string | undefined; }> +shareDatabase({ databaseName: 'tdb' }) + +// $ExpectType Promise<{ shareToken?: string | undefined; }> +shareDatabase({ databaseId: 'tdb' }) + +// $ExpectType Promise<{ shareToken?: string | undefined; }> shareDatabase({ databaseId: 'tid', username: 'tuser', requireVerified: true, readOnly: true, resharingAllowed: true }) // $ExpectError -shareDatabase({ databaseId: 'tid' }) +shareDatabase({ username: 'tuser' }) // $ExpectError -shareDatabase({ username: 'tuser' }) +shareDatabase({ shareToken: 'tst' }) // $ExpectType Promise modifyDatabasePermissions({ databaseName: 'tdb', username: 'tuser' }) From 86a1144e87db298cb3faf4024ccd848cbb1d4279 Mon Sep 17 00:00:00 2001 From: Justin Berman Date: Sun, 13 Dec 2020 22:57:28 -0800 Subject: [PATCH 3/7] Broadcast userId + connectionId to peers for cleaner logs --- src/userbase-server/db.js | 2 +- src/userbase-server/peers.js | 7 ++++--- src/userbase-server/server.js | 5 +++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/userbase-server/db.js b/src/userbase-server/db.js index e649a920..0ea5e1c1 100644 --- a/src/userbase-server/db.js +++ b/src/userbase-server/db.js @@ -710,7 +710,7 @@ const putTransaction = async function (transaction, userId, connectionId, databa connections.push(transaction) // broadcast transaction to all peers so they also push to their connected clients - peers.broadcastTransaction(transaction) + peers.broadcastTransaction(transaction, userId, connectionId) return transaction['sequence-no'] } diff --git a/src/userbase-server/peers.js b/src/userbase-server/peers.js index 11c295fa..21e4daaf 100644 --- a/src/userbase-server/peers.js +++ b/src/userbase-server/peers.js @@ -13,7 +13,7 @@ export default class Peers { return notifications } - static async broadcastTransaction(transaction, userId) { + static async broadcastTransaction(transaction, userId, connectionId) { // best effort notify all other peers of transaction const notify = async (ipAddress) => { try { @@ -22,14 +22,15 @@ export default class Peers { uri: `http://${ipAddress}:9000/internal/notify-transaction`, body: { transaction, - userId + userId, + connectionId, }, json: true }).promise() } catch (e) { logger - .child({ userId, databaseId: transaction['database-id'], ipAddress, err: e }) + .child({ databaseId: transaction['database-id'], userId, connectionId, ipAddress, err: e }) .warn('Failed to notify db update') } } diff --git a/src/userbase-server/server.js b/src/userbase-server/server.js index af375ed6..8ccd2752 100755 --- a/src/userbase-server/server.js +++ b/src/userbase-server/server.js @@ -708,15 +708,16 @@ async function start(express, app, userbaseConfig = {}) { internalServer.post('/internal/notify-transaction', (req, res) => { const transaction = req.body.transaction const userId = req.body.userId + const connectionId = req.body.connectionId let logChildObject try { - logChildObject = { userId, databaseId: transaction['database-id'], seqNo: transaction['seq-no'], req: trimReq(req) } + logChildObject = { userId, connectionId, databaseId: transaction['database-id'], seqNo: transaction['seq-no'], req: trimReq(req) } logger .child(logChildObject) .info('Received internal notification to update db') - connections.push(transaction, userId) + connections.push(transaction) } catch (e) { const msg = 'Error pushing internal transaction to connected clients' logger.child({ ...logChildObject, err: e }).error(msg) From 26fadc80149fdc1705e565ba8ed7052331fa7942 Mon Sep 17 00:00:00 2001 From: Justin Berman Date: Tue, 15 Dec 2020 00:42:36 -0800 Subject: [PATCH 4/7] Fix: pass encryption mode to share functions internally --- src/userbase-js/src/db.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/userbase-js/src/db.js b/src/userbase-js/src/db.js index 2a489339..b2841be1 100644 --- a/src/userbase-js/src/db.js +++ b/src/userbase-js/src/db.js @@ -2034,8 +2034,8 @@ const shareDatabase = async (params) => { _validateEncryptionMode(encryptionMode) let result = {} - if (objectHasOwnProperty(params, 'username')) await _shareDatabaseWithUsername(params, readOnly, resharingAllowed, requireVerified) - else result.shareToken = await _getShareToken(params, readOnly) + if (objectHasOwnProperty(params, 'username')) await _shareDatabaseWithUsername(params, readOnly, resharingAllowed, requireVerified, encryptionMode) + else result.shareToken = await _getShareToken(params, readOnly, encryptionMode) return result } catch (e) { From 81515c0b1b6528b6d2cd47f32a770394ffd486f4 Mon Sep 17 00:00:00 2001 From: Justin Berman Date: Wed, 16 Dec 2020 02:28:22 -0800 Subject: [PATCH 5/7] Allow db owner to generate 1 read share token & 1 write share token --- src/userbase-js/src/db.js | 66 +++++++++-------- src/userbase-js/src/errors/db.js | 10 +-- src/userbase-js/src/ws.js | 27 ++++--- src/userbase-server/db.js | 118 +++++++++++++++++++++---------- src/userbase-server/server.js | 2 +- src/userbase-server/setup.js | 24 ++++++- src/userbase-server/ws.js | 31 ++++---- 7 files changed, 179 insertions(+), 99 deletions(-) diff --git a/src/userbase-js/src/db.js b/src/userbase-js/src/db.js index b2841be1..5371a00d 100644 --- a/src/userbase-js/src/db.js +++ b/src/userbase-js/src/db.js @@ -114,7 +114,7 @@ class UnverifiedTransaction { } class Database { - constructor(changeHandler, receivedMessage, shareTokenHkdfKey) { + constructor(changeHandler, receivedMessage, shareTokenId, shareTokenHkdfKey) { this.onChange = _setChangeHandler(changeHandler) this.items = {} @@ -138,6 +138,8 @@ class Database { this.receivedMessage = receivedMessage this.usernamesByUserId = new Map() this.attributionEnabled = false + + this.shareTokenId = shareTokenId this.shareTokenHkdfKey = shareTokenHkdfKey // Queue that ensures 'ApplyTransactions' executes one at a time @@ -526,40 +528,41 @@ const _idempotentOpenDatabase = (database, changeHandler, receivedMessage) => { return false } -const _getDatabaseIdFromShareToken = (shareTokenArrayBuffer) => { - const dbIdArrayBuffer = shareTokenArrayBuffer.slice(shareTokenArrayBuffer.byteLength - UUID_CHAR_LENGTH) - const databaseId = arrayBufferToString(dbIdArrayBuffer, true) - if (!databaseId || databaseId.length !== UUID_CHAR_LENGTH) throw new errors.ShareTokenInvalid - return databaseId +const _getShareTokenIdFromShareToken = (shareTokenArrayBuffer) => { + const shareTokenIdArrayBuffer = shareTokenArrayBuffer.slice(0, UUID_CHAR_LENGTH) + const shareTokenId = arrayBufferToString(shareTokenIdArrayBuffer, true) + if (!shareTokenId || shareTokenId.length !== UUID_CHAR_LENGTH) throw new errors.ShareTokenInvalid + return shareTokenId } -const _getDatabaseIdAndShareToken = (shareTokenResult) => { +const _getShareTokenIdAndShareTokenSeed = (shareTokenResult) => { const shareTokenArrayBuffer = base64.decode(shareTokenResult) - const databaseId = _getDatabaseIdFromShareToken(shareTokenArrayBuffer) - const shareToken = shareTokenArrayBuffer.slice(0, shareTokenArrayBuffer.byteLength - UUID_CHAR_LENGTH) - return { databaseId, shareToken } + const shareTokenId = _getShareTokenIdFromShareToken(shareTokenArrayBuffer) + const shareTokenSeed = shareTokenArrayBuffer.slice(UUID_CHAR_LENGTH) + return { shareTokenId, shareTokenSeed } } -const _openDatabaseByShareToken = async (shareTokenResult, changeHandler, receivedMessage) => { - let databaseIdAndShareToken, shareTokenHkdfKey +const _openDatabaseByShareToken = async (shareToken, changeHandler, receivedMessage) => { + let shareTokenIdAndShareTokenSeed, shareTokenHkdfKey try { - databaseIdAndShareToken = _getDatabaseIdAndShareToken(shareTokenResult) - shareTokenHkdfKey = await crypto.hkdf.importHkdfKey(databaseIdAndShareToken.shareToken) + shareTokenIdAndShareTokenSeed = _getShareTokenIdAndShareTokenSeed(shareToken) + shareTokenHkdfKey = await crypto.hkdf.importHkdfKey(shareTokenIdAndShareTokenSeed.shareTokenSeed) } catch { throw new errors.ShareTokenInvalid } - const { databaseId } = databaseIdAndShareToken + const { shareTokenId } = shareTokenIdAndShareTokenSeed - const { validationMessage, signedValidationMessage } = await ws.authenticateShareToken(databaseId, shareTokenHkdfKey) + const { databaseId, validationMessage, signedValidationMessage } = await ws.authenticateShareToken(shareTokenId, shareTokenHkdfKey) + ws.state.shareTokenIdToDbId[shareTokenId] = databaseId - await _openDatabaseByDatabaseId(databaseId, changeHandler, receivedMessage, shareTokenHkdfKey, validationMessage, signedValidationMessage) + await _openDatabaseByDatabaseId(databaseId, changeHandler, receivedMessage, shareTokenId, shareTokenHkdfKey, validationMessage, signedValidationMessage) } -const _openDatabaseByDatabaseId = async (databaseId, changeHandler, receivedMessage, shareTokenHkdfKey, validationMessage, signedValidationMessage) => { +const _openDatabaseByDatabaseId = async (databaseId, changeHandler, receivedMessage, shareTokenId, shareTokenHkdfKey, validationMessage, signedValidationMessage) => { const database = ws.state.databasesByDbId[databaseId] if (!database) { - ws.state.databasesByDbId[databaseId] = new Database(changeHandler, receivedMessage, shareTokenHkdfKey) + ws.state.databasesByDbId[databaseId] = new Database(changeHandler, receivedMessage, shareTokenId, shareTokenHkdfKey) } else { if (_idempotentOpenDatabase(database, changeHandler, receivedMessage)) return } @@ -757,7 +760,7 @@ const openDatabase = async (params) => { case 'DatabaseIdNotAllowedForOwnDatabase': case 'ShareTokenNotAllowed': case 'ShareTokenInvalid': - case 'ShareTokenExpired': + case 'ShareTokenNotFound': case 'ShareTokenNotAllowedForOwnDatabase': case 'DatabaseNotFound': case 'ChangeHandlerMissing': @@ -783,12 +786,12 @@ const openDatabase = async (params) => { const getOpenDb = (dbName, databaseId, shareToken, encryptionMode = 'end-to-end') => { _validateEncryptionMode(encryptionMode) - const dbId = !dbName && (databaseId || _getDatabaseIdFromShareToken(base64.decode(shareToken))) + const shareTokenId = shareToken && _getShareTokenIdFromShareToken(base64.decode(shareToken)) const dbNameHash = encryptionMode === 'server-side' ? dbName : ws.state.dbNameToHash[dbName] const database = dbName ? ws.state.databases[dbNameHash] - : ws.state.databasesByDbId[dbId] + : ws.state.databasesByDbId[databaseId || ws.state.shareTokenIdToDbId[shareTokenId]] if (!database || !database.init) throw new errors.DatabaseNotOpen return database @@ -1866,9 +1869,9 @@ const _getShareToken = async (params, readOnly, encryptionMode) => { if (objectHasOwnProperty(params, 'requireVerified')) throw new errors.RequireVerifiedParamNotNecessary if (objectHasOwnProperty(params, 'resharingAllowed')) throw new errors.ResharingAllowedParamNotAllowed('when retrieving a share token') - // generate share token and associated keys - const shareToken = crypto.generateSeed() - const shareTokenHkdfKey = await crypto.hkdf.importHkdfKey(shareToken) + // generate share token seed and associated keys + const shareTokenSeed = crypto.generateSeed() + const shareTokenHkdfKey = await crypto.hkdf.importHkdfKey(shareTokenSeed) // generate share token encryption key const shareTokenEncryptionKeySalt = crypto.hkdf.generateSalt() @@ -1895,12 +1898,15 @@ const _getShareToken = async (params, readOnly, encryptionMode) => { shareTokenEcdsaKeyEncryptionKeySalt: ecdsaKeyEncryptionKeySalt, } } - await ws.request(action, requestParams) + const shareTokenResponse = await ws.request(action, requestParams) + + // server generates unique ID + const { shareTokenId } = shareTokenResponse.data - // append dbId to shareToken to get final shareToken to return to user, all in base64 - const dbIdArrayBuffer = stringToArrayBuffer(database.dbId, true) - const shareTokenResult = base64.encode(appendBuffer(shareToken, dbIdArrayBuffer)) - return shareTokenResult + // prepend shareTokenId to shareTokenSeed to get final shareToken to return to user, all in base64 + const shareTokenIdArrayBuffer = stringToArrayBuffer(shareTokenId, true) + const shareToken = base64.encode(appendBuffer(shareTokenIdArrayBuffer, shareTokenSeed)) + return shareToken } catch (e) { _parseGenericErrors(e) diff --git a/src/userbase-js/src/errors/db.js b/src/userbase-js/src/errors/db.js index c1e9f888..a8a5ded3 100644 --- a/src/userbase-js/src/errors/db.js +++ b/src/userbase-js/src/errors/db.js @@ -190,13 +190,13 @@ class ShareTokenInvalid extends Error { } } -class ShareTokenExpired extends Error { +class ShareTokenNotFound extends Error { constructor(...params) { super(...params) - this.name = 'ShareTokenExpired' - this.message = 'Share token expired. The database owner has generated a new share token.' - this.status = statusCodes['Forbidden'] + this.name = 'ShareTokenNotFound' + this.message = 'Share token not found. Perhaps the database owner has generated a new share token.' + this.status = statusCodes['Not Found'] } } @@ -785,7 +785,7 @@ export default { DatabaseIdNotAllowedForOwnDatabase, DatabaseIdInvalidLength, ShareTokenInvalid, - ShareTokenExpired, + ShareTokenNotFound, ShareTokenNotAllowed, ShareTokenNotAllowedForOwnDatabase, ReadOnlyMustBeBoolean, diff --git a/src/userbase-js/src/ws.js b/src/userbase-js/src/ws.js index baf6a13b..6e58fd62 100644 --- a/src/userbase-js/src/ws.js +++ b/src/userbase-js/src/ws.js @@ -82,7 +82,8 @@ class Connection { this.state = state || { dbNameToHash: {}, databases: {}, // used when openDatabase is called with databaseName - databasesByDbId: {} // used when openDatabase is called with databaseId + databasesByDbId: {}, // used when openDatabase is called with databaseId + shareTokenIdToDbId: {}, // used when openDatabase is called with shareToken } this.encryptionMode = encryptionMode @@ -354,7 +355,8 @@ class Connection { const state = currentState || { dbNameToHash: { ...this.state.dbNameToHash }, databases: { ...this.state.databases }, - databasesByDbId: { ...this.state.databasesByDbId } + databasesByDbId: { ...this.state.databasesByDbId }, + shareTokenIdToDbId: { ...this.state.shareTokenIdToDbId }, } // mark databases as uninitialized to prevent client from using them until they are reopened @@ -414,7 +416,7 @@ class Connection { // if opened with shareToken, need to reauthenticate it const shareTokenAuthData = shareTokenHkdfKey - ? await this.authenticateShareToken(databaseId, shareTokenHkdfKey) + ? await this.authenticateShareToken(database.shareTokenId, shareTokenHkdfKey) : {} const action = 'OpenDatabaseByDatabaseId' @@ -689,12 +691,19 @@ class Connection { this.keys.init = true } - async authenticateShareToken(databaseId, shareTokenHkdfKey) { + async authenticateShareToken(shareTokenId, shareTokenHkdfKey) { // retrieve shareToken auth key data in order to prove access to shareToken to server const action = 'AuthenticateShareToken' - const params = { databaseId } - const response = await this.request(action, params) - const { shareTokenAuthKeyData, validationMessage } = response.data + const params = { shareTokenId } + + let response + try { + response = await this.request(action, params) + } catch (e) { + if (e.response && e.response.data === 'ShareTokenNotFound') throw new errors.ShareTokenNotFound + throw e + } + const { databaseId, shareTokenAuthKeyData, validationMessage } = response.data // decrypt ECDSA private key. if it fails, not using the correct shareToken let shareTokenEcdsaPrivateKey @@ -705,12 +714,12 @@ class Connection { const shareTokenEcdsaPrivateKeyRaw = await crypto.aesGcm.decrypt(shareTokenEcdsaKeyEncryptionKey, shareTokenEncryptedEcdsaPrivateKey) shareTokenEcdsaPrivateKey = await crypto.ecdsa.getPrivateKeyFromRawPrivateKey(shareTokenEcdsaPrivateKeyRaw) } catch { - throw new errors.ShareTokenExpired + throw new errors.ShareTokenInvalid } // sign validation message sent by the server const signedValidationMessage = await crypto.ecdsa.sign(shareTokenEcdsaPrivateKey, base64.decode(validationMessage)) - return { validationMessage, signedValidationMessage: base64.encode(signedValidationMessage) } + return { databaseId, validationMessage, signedValidationMessage: base64.encode(signedValidationMessage) } } } diff --git a/src/userbase-server/db.js b/src/userbase-server/db.js index 0ea5e1c1..b9b3ed6a 100644 --- a/src/userbase-server/db.js +++ b/src/userbase-server/db.js @@ -236,16 +236,19 @@ exports.openDatabase = async function (user, app, admin, connectionId, dbNameHas } const _validateAuthTokenSignature = (userId, database, validationMessage, signedValidationMessage) => { - if (!connections.isShareTokenValidationMessageCached(userId, validationMessage)) throw { + const shareTokenReadWritePermissions = connections.getShareTokenReadWritePermissionsFromCache(userId, validationMessage) + if (!shareTokenReadWritePermissions) throw { status: statusCodes['Unauthorized'], error: 'RequestExpired' } - const shareTokenPublicKey = database['share-token-public-key'] + const shareTokenPublicKey = database['share-token-public-key-' + shareTokenReadWritePermissions] if (!crypto.ecdsa.verify(Buffer.from(validationMessage, 'base64'), shareTokenPublicKey, signedValidationMessage)) throw { status: statusCodes['Unauthorized'], - error: 'ShareTokenExpired' + error: 'ShareTokenInvalid' } + + return shareTokenReadWritePermissions } exports.openDatabaseByDatabaseId = async function (userAtSignIn, app, admin, connectionId, databaseId, validationMessage, signedValidationMessage, reopenAtSeqNo) { @@ -274,10 +277,11 @@ exports.openDatabaseByDatabaseId = async function (userAtSignIn, app, admin, con const plaintextDbKey = db['plaintext-db-key'] const connectionParams = { userId, connectionId, databaseId, bundleSeqNo, reopenAtSeqNo, isOwner, attribution, plaintextDbKey } if (validationMessage) { - _validateAuthTokenSignature(userId, db, validationMessage, signedValidationMessage) + const shareTokenReadWritePermissions = _validateAuthTokenSignature(userId, db, validationMessage, signedValidationMessage) - connectionParams.shareTokenEncryptedDbKey = db['share-token-encrypted-db-key'] - connectionParams.shareTokenEncryptionKeySalt = db['share-token-encryption-key-salt'] + connectionParams.shareTokenEncryptedDbKey = db['share-token-encrypted-db-key-' + shareTokenReadWritePermissions] + connectionParams.shareTokenEncryptionKeySalt = db['share-token-encryption-key-salt-' + shareTokenReadWritePermissions] + connectionParams.shareTokenReadWritePermissions = shareTokenReadWritePermissions } else { connectionParams.dbNameHash = userDb['database-name-hash'] const dbKey = userDb['encrypted-db-key'] @@ -652,7 +656,7 @@ const putTransaction = async function (transaction, userId, connectionId, databa error: { name: 'DatabaseNotOpen' } } - const openedWithShareToken = connections.isDatabaseOpenWithShareToken(userId, connectionId, databaseId) + const shareTokenReadWritePermissions = connections.getShareTokenReadWritePermissionsFromConnection(userId, connectionId, databaseId) // can be determined now, but not needed until later const userPromise = userController.getUserByUserId(userId) @@ -660,8 +664,8 @@ const putTransaction = async function (transaction, userId, connectionId, databa // incrementeSeqNo is only thing that needs to be done here, but making requests async to keep the // time for successful putTransaction low const [userDb, db] = await Promise.all([ - !openedWithShareToken && _getUserDatabaseByUserIdAndDatabaseId(userId, databaseId), - openedWithShareToken && findDatabaseByDatabaseId(databaseId), + !shareTokenReadWritePermissions && _getUserDatabaseByUserIdAndDatabaseId(userId, databaseId), + shareTokenReadWritePermissions && findDatabaseByDatabaseId(databaseId), _incrementSeqNo(transaction, databaseId) ]) @@ -675,7 +679,7 @@ const putTransaction = async function (transaction, userId, connectionId, databa status: statusCodes['Not Found'], error: { name: 'DatabaseNotFound' } } - } else if (openedWithShareToken ? db['share-token-read-only'] : userDb['read-only']) { + } else if (shareTokenReadWritePermissions ? shareTokenReadWritePermissions === 'read-only' : userDb['read-only']) { throw { status: statusCodes['Forbidden'], error: { name: 'DatabaseIsReadOnly' } @@ -968,14 +972,11 @@ exports.generateFileId = async function (logChildObject, userId, connectionId, d return responseBuilder.errorResponse(statusCodes['Bad Request'], 'Database not open') } - const openedWithShareToken = connections.isDatabaseOpenWithShareToken(userId, connectionId, databaseId) + const shareTokenReadWritePermissions = connections.getShareTokenReadWritePermissionsFromConnection(userId, connectionId, databaseId) - const [userDb, db] = await Promise.all([ - !openedWithShareToken && _getUserDatabaseByUserIdAndDatabaseId(userId, databaseId), - openedWithShareToken && findDatabaseByDatabaseId(databaseId), - ]) + const userDb = !shareTokenReadWritePermissions && await _getUserDatabaseByUserIdAndDatabaseId(userId, databaseId) - if (!openedWithShareToken ? userDb['read-only'] : db['share-token-read-only']) { + if (!shareTokenReadWritePermissions ? userDb['read-only'] : shareTokenReadWritePermissions === 'read-only') { throw { status: statusCodes['Forbidden'], error: { message: 'DatabaseIsReadOnly' } @@ -1267,41 +1268,44 @@ exports.shareDatabaseToken = async function (logChildObject, sender, dbId, dbNam shareTokenEcdsaKeyEncryptionKeySalt, } = keyData + const shareTokenReadWritePermissions = readOnly ? 'read-only' : 'write' + const shareTokenId = uuidv4() + const params = { TableName: setup.databaseTableName, Key: { 'database-id': dbId, }, UpdateExpression: `SET + #shareTokenId = :shareTokenId, #shareTokenEncryptedDbKey = :shareTokenEncryptedDbKey, #shareTokenEncryptionKeySalt = :shareTokenEncryptionKeySalt, #shareTokenPublicKey = :shareTokenPublicKey, #shareTokenEncryptedEcdsaPrivateKey = :shareTokenEncryptedEcdsaPrivateKey, - #shareTokenEcdsaKeyEncryptionKeySalt = :shareTokenEcdsaKeyEncryptionKeySalt, - #shareTokenReadOnly = :shareTokenReadOnly + #shareTokenEcdsaKeyEncryptionKeySalt = :shareTokenEcdsaKeyEncryptionKeySalt `, ExpressionAttributeNames: { - '#shareTokenEncryptedDbKey': 'share-token-encrypted-db-key', - '#shareTokenEncryptionKeySalt': 'share-token-encryption-key-salt', - '#shareTokenPublicKey': 'share-token-public-key', - '#shareTokenEncryptedEcdsaPrivateKey': 'share-token-encrypted-ecdsa-private-key', - '#shareTokenEcdsaKeyEncryptionKeySalt': 'share-token-ecdsa-key-encryption-key-salt', - '#shareTokenReadOnly': 'share-token-read-only', + '#shareTokenId': 'share-token-id-' + shareTokenReadWritePermissions, + '#shareTokenEncryptedDbKey': 'share-token-encrypted-db-key-' + shareTokenReadWritePermissions, + '#shareTokenEncryptionKeySalt': 'share-token-encryption-key-salt-' + shareTokenReadWritePermissions, + '#shareTokenPublicKey': 'share-token-public-key-' + shareTokenReadWritePermissions, + '#shareTokenEncryptedEcdsaPrivateKey': 'share-token-encrypted-ecdsa-private-key-' + shareTokenReadWritePermissions, + '#shareTokenEcdsaKeyEncryptionKeySalt': 'share-token-ecdsa-key-encryption-key-salt-' + shareTokenReadWritePermissions, }, ExpressionAttributeValues: { + ':shareTokenId': shareTokenId, ':shareTokenEncryptedDbKey': shareTokenEncryptedDbKey, ':shareTokenEncryptionKeySalt': shareTokenEncryptionKeySalt, ':shareTokenPublicKey': shareTokenPublicKey, ':shareTokenEncryptedEcdsaPrivateKey': shareTokenEncryptedEcdsaPrivateKey, ':shareTokenEcdsaKeyEncryptionKeySalt': shareTokenEcdsaKeyEncryptionKeySalt, - ':shareTokenReadOnly': readOnly } } const ddbClient = connection.ddbClient() await ddbClient.update(params).promise() - return responseBuilder.successResponse('Success!') + return responseBuilder.successResponse({ shareTokenId }) } catch (e) { logChildObject.err = e @@ -1313,32 +1317,68 @@ exports.shareDatabaseToken = async function (logChildObject, sender, dbId, dbNam } } -exports.authenticateShareToken = async function (logChildObject, user, dbId) { +const _findDatabaseByShareTokenId = async (shareTokenId) => { + const params = { + TableName: setup.databaseTableName, + KeyConditionExpression: '#shareTokenId = :shareTokenId', + Select: 'ALL_ATTRIBUTES' + } + + const readOnlyParams = { + ...params, + IndexName: setup.shareTokenIdReadOnlyIndex, + ExpressionAttributeNames: { + '#shareTokenId': 'share-token-id-read-only', + }, + ExpressionAttributeValues: { + ':shareTokenId': shareTokenId, + }, + } + + const writeParams = { + ...params, + IndexName: setup.shareTokenIdWriteIndex, + ExpressionAttributeNames: { + '#shareTokenId': 'share-token-id-write', + }, + ExpressionAttributeValues: { + ':shareTokenId': shareTokenId, + }, + } + + const ddbClient = connection.ddbClient() + const [readOnlyResponse, writeResponse] = await Promise.all([ + ddbClient.query(readOnlyParams).promise(), + ddbClient.query(writeParams).promise(), + ]) + + if (readOnlyResponse.Items.length === 0 && writeResponse.Items.length === 0) return null + return readOnlyResponse.Items[0] || writeResponse.Items[0] +} + +exports.authenticateShareToken = async function (logChildObject, user, shareTokenId) { try { - const database = await findDatabaseByDatabaseId(dbId) + const database = await _findDatabaseByShareTokenId(shareTokenId) if (!database) throw { status: statusCodes['Not Found'], - error: { message: 'DatabaseNotFound' } + error: 'ShareTokenNotFound' } - const shareTokenAuthKeyData = { - shareTokenEncryptedEcdsaPrivateKey: database['share-token-encrypted-ecdsa-private-key'], - shareTokenEcdsaKeyEncryptionKeySalt: database['share-token-ecdsa-key-encryption-key-salt'], - } + const shareTokenReadWritePermissions = database['share-token-id-read-only'] === shareTokenId ? 'read-only' : 'write' - if (!shareTokenAuthKeyData.shareTokenEncryptedEcdsaPrivateKey) throw { - status: statusCodes['Not Found'], - error: { message: 'ShareTokenNotFound' } + const shareTokenAuthKeyData = { + shareTokenEncryptedEcdsaPrivateKey: database['share-token-encrypted-ecdsa-private-key-' + shareTokenReadWritePermissions], + shareTokenEcdsaKeyEncryptionKeySalt: database['share-token-ecdsa-key-encryption-key-salt-' + shareTokenReadWritePermissions], } // user must sign this message to open the database with share token const validationMessage = crypto.randomBytes(VALIDATION_MESSAGE_LENGTH).toString('base64') - // cache the validation message so server can validate access to share token on open - connections.cacheShareTokenValidationMessage(user['user-id'], validationMessage) + // cache the read-write permissions keyed by validation message so server can validate access to share token on open + connections.cacheShareTokenReadWritePermissions(user['user-id'], validationMessage, shareTokenReadWritePermissions) - return responseBuilder.successResponse({ shareTokenAuthKeyData, validationMessage }) + return responseBuilder.successResponse({ databaseId: database['database-id'], shareTokenAuthKeyData, validationMessage }) } catch (e) { logChildObject.err = e diff --git a/src/userbase-server/server.js b/src/userbase-server/server.js index 8ccd2752..fd71cff3 100755 --- a/src/userbase-server/server.js +++ b/src/userbase-server/server.js @@ -400,7 +400,7 @@ async function start(express, app, userbaseConfig = {}) { break } case 'AuthenticateShareToken': { - response = await db.authenticateShareToken(logChildObject, res.locals.user, params.databaseId) + response = await db.authenticateShareToken(logChildObject, res.locals.user, params.shareTokenId) break } case 'SaveDatabase': { diff --git a/src/userbase-server/setup.js b/src/userbase-server/setup.js index cdf2e47c..8060780b 100644 --- a/src/userbase-server/setup.js +++ b/src/userbase-server/setup.js @@ -64,6 +64,8 @@ const accessTokenIndex = 'AccessTokenIndex' const userIdIndex = 'UserIdIndex' const appIdIndex = 'AppIdIndex' const authTokenIndex = 'AuthTokenIndex' +const shareTokenIdReadOnlyIndex = 'ShareTokenIdReadOnlyIndex' +const shareTokenIdWriteIndex = 'ShareTokenIdWriteIndex' const subscriptionPlanIndex = 'SubscriptionPlanIndex' const userDatabaseIdIndex = 'UserDatabaseIdIndex' @@ -72,6 +74,8 @@ exports.accessTokenIndex = accessTokenIndex exports.userIdIndex = userIdIndex exports.appIdIndex = appIdIndex exports.authTokenIndex = authTokenIndex +exports.shareTokenIdReadOnlyIndex = shareTokenIdReadOnlyIndex +exports.shareTokenIdWriteIndex = shareTokenIdWriteIndex exports.subscriptionPlanIndex = subscriptionPlanIndex exports.userDatabaseIdIndex = userDatabaseIdIndex @@ -292,13 +296,31 @@ async function setupDdb() { // the database table holds a record per database const databaseTableParams = { AttributeDefinitions: [ - { AttributeName: 'database-id', AttributeType: 'S' } + { AttributeName: 'database-id', AttributeType: 'S' }, + { AttributeName: 'share-token-id-read-only', AttributeType: 'S' }, + { AttributeName: 'share-token-id-write', AttributeType: 'S' }, ], KeySchema: [ { AttributeName: 'database-id', KeyType: 'HASH' }, ], BillingMode: 'PAY_PER_REQUEST', TableName: databaseTableName, + GlobalSecondaryIndexes: [ + { + IndexName: shareTokenIdReadOnlyIndex, + KeySchema: [ + { AttributeName: 'share-token-id-read-only', KeyType: 'HASH' } + ], + Projection: { ProjectionType: 'ALL' } + }, + { + IndexName: shareTokenIdWriteIndex, + KeySchema: [ + { AttributeName: 'share-token-id-write', KeyType: 'HASH' } + ], + Projection: { ProjectionType: 'ALL' } + } + ] } // the user database table holds a record for each user database relationship diff --git a/src/userbase-server/ws.js b/src/userbase-server/ws.js index b45fb041..5b0cb50b 100644 --- a/src/userbase-server/ws.js +++ b/src/userbase-server/ws.js @@ -65,7 +65,7 @@ class Connection { this.fileStorageRateLimiter = new TokenBucket(FILE_STORAGE_MAX_REQUESTS_PER_SECOND, FILE_STORAGE_TOKENS_REFILLED_PER_SECOND) } - openDatabase(databaseId, dbNameHash, bundleSeqNo, reopenAtSeqNo, isOwner, attribution, usedShareToken) { + openDatabase(databaseId, dbNameHash, bundleSeqNo, reopenAtSeqNo, isOwner, attribution, shareTokenReadWritePermissions) { this.databases[databaseId] = { bundleSeqNo: bundleSeqNo > 0 ? bundleSeqNo : -1, lastSeqNo: reopenAtSeqNo || 0, @@ -74,7 +74,7 @@ class Connection { dbNameHash, isOwner, attribution, - usedShareToken, + shareTokenReadWritePermissions, } } @@ -403,20 +403,19 @@ export default class Connections { } static openDatabase({ userId, connectionId, databaseId, bundleSeqNo, dbNameHash, dbKey, reopenAtSeqNo, isOwner, - attribution, plaintextDbKey, shareTokenEncryptedDbKey, shareTokenEncryptionKeySalt }) { + attribution, plaintextDbKey, shareTokenEncryptedDbKey, shareTokenEncryptionKeySalt, shareTokenReadWritePermissions }) { if (!Connections.sockets || !Connections.sockets[userId] || !Connections.sockets[userId][connectionId]) return const conn = Connections.sockets[userId][connectionId] if (!conn.databases[databaseId]) { - const usedShareToken = shareTokenEncryptedDbKey ? true : false - conn.openDatabase(databaseId, dbNameHash, bundleSeqNo, reopenAtSeqNo, isOwner, attribution, usedShareToken) + conn.openDatabase(databaseId, dbNameHash, bundleSeqNo, reopenAtSeqNo, isOwner, attribution, shareTokenReadWritePermissions) if (!Connections.sockets[databaseId]) Connections.sockets[databaseId] = { numConnections: 0 } Connections.sockets[databaseId][connectionId] = userId Connections.sockets[databaseId].numConnections += 1 - logger.child({ connectionId, databaseId, adminId: conn.adminId, encryptionMode: plaintextDbKey ? 'server-side' : 'end-to-end', usedShareToken }).info('Database opened') + logger.child({ connectionId, databaseId, adminId: conn.adminId, encryptionMode: plaintextDbKey ? 'server-side' : 'end-to-end', shareTokenReadWritePermissions }).info('Database opened') } conn.push(databaseId, dbNameHash, dbKey, reopenAtSeqNo, plaintextDbKey, shareTokenEncryptedDbKey, shareTokenEncryptionKeySalt) @@ -432,12 +431,12 @@ export default class Connections { return conn.databases[databaseId] ? true : false } - static isDatabaseOpenWithShareToken(userId, connectionId, databaseId) { + static getShareTokenReadWritePermissionsFromConnection(userId, connectionId, databaseId) { if (!Connections.sockets || !Connections.sockets[userId] || !Connections.sockets[userId][connectionId]) return const conn = Connections.sockets[userId][connectionId] - return (conn.databases[databaseId] && conn.databases[databaseId].usedShareToken) ? true : false + return conn.databases[databaseId] && conn.databases[databaseId].shareTokenReadWritePermissions } static push(transaction) { @@ -565,11 +564,13 @@ export default class Connections { return true } - static cacheShareTokenValidationMessage(userId, validationMessage) { + static cacheShareTokenReadWritePermissions(userId, validationMessage, shareTokenReadWritePermissions) { if (!Connections.sockets || !Connections.sockets[userId] || !Connections.sockets[userId].shareTokenValidationMessages) return + Connections.sockets[userId].shareTokenValidationMessages[validationMessage] = shareTokenReadWritePermissions + // after CACHE_LIFE seconds, delete the validationMessage from the cache - Connections.sockets[userId].shareTokenValidationMessages[validationMessage] = setTimeout(() => { + setTimeout(() => { if (Connections.sockets && Connections.sockets[userId] && Connections.sockets[userId].shareTokenValidationMessages) { delete Connections.sockets[userId].shareTokenValidationMessages[validationMessage] } @@ -578,9 +579,11 @@ export default class Connections { ) } - static isShareTokenValidationMessageCached(userId, validationMessage) { - if (!Connections.sockets || !Connections.sockets[userId] || !Connections.sockets[userId].shareTokenValidationMessages || - !Connections.sockets[userId].shareTokenValidationMessages[validationMessage]) return false - return true + static getShareTokenReadWritePermissionsFromCache(userId, validationMessage) { + const shareTokenReadWritePermissions = Connections.sockets && Connections.sockets[userId] && + Connections.sockets[userId].shareTokenValidationMessages && + Connections.sockets[userId].shareTokenValidationMessages[validationMessage] + + return shareTokenReadWritePermissions } } From 25b36e977993dee47446d016e9b199e892f1e379 Mon Sep 17 00:00:00 2001 From: Justin Berman Date: Wed, 16 Dec 2020 02:28:45 -0800 Subject: [PATCH 6/7] More tests for sharing + share token --- .../Database Sharing/share-database.spec.js | 356 +++++++++++++++++- 1 file changed, 352 insertions(+), 4 deletions(-) diff --git a/cypress/integration/Database Sharing/share-database.spec.js b/cypress/integration/Database Sharing/share-database.spec.js index 28a5ac9a..5d6a5b31 100644 --- a/cypress/integration/Database Sharing/share-database.spec.js +++ b/cypress/integration/Database Sharing/share-database.spec.js @@ -609,6 +609,111 @@ describe('DB Sharing Tests', function () { await this.test.userbase.deleteUser() }) + it('Sharing without opening first', async function () { + const recipient = await signUp(this.test.userbase) + const { verificationMessage } = await this.test.userbase.getVerificationMessage() + await this.test.userbase.signOut() + + const sender = await signUp(this.test.userbase) + await this.test.userbase.verifyUser({ verificationMessage }) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // sender inserts item into database + const testItem = 'hello world!' + const testItemId = 'test-id' + await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) + + // sign out, sign back in + await this.test.userbase.signOut() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + + // sender shares database with recipient without opening first + await this.test.userbase.shareDatabase({ databaseName, username: recipient.username }) + await this.test.userbase.signOut() + + // recipient signs in and checks if can read the database + await this.test.userbase.signIn({ username: recipient.username, password: recipient.password, rememberMe: 'none' }) + + // recipient must find the database's databaseId using getDatabases() result + const { databases } = await this.test.userbase.getDatabases() + const db = databases[0] + const { databaseId } = db + + let changeHandlerCallCount = 0 + const changeHandler = function (items) { + expect(items, 'array passed to changeHandler').to.be.a('array') + expect(items, 'array passed to changeHandler').to.deep.equal([{ + itemId: testItemId, + item: testItem, + createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp } + }]) + + changeHandlerCallCount += 1 + } + + await this.test.userbase.openDatabase({ databaseId, changeHandler }) + + expect(changeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) + + // clean up + await this.test.userbase.deleteUser() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + await this.test.userbase.deleteUser() + }) + + it('Share by databaseId without opening first', async function () { + const recipient = await signUp(this.test.userbase) + const { verificationMessage } = await this.test.userbase.getVerificationMessage() + await this.test.userbase.signOut() + + const sender = await signUp(this.test.userbase) + await this.test.userbase.verifyUser({ verificationMessage }) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // sender inserts item into database + const testItem = 'hello world!' + const testItemId = 'test-id' + await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) + + // get database's id + const { databases: [{ databaseId }] } = await this.test.userbase.getDatabases() + + // sign out, sign back in + await this.test.userbase.signOut() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + + // sender shares database with recipient without opening + await this.test.userbase.shareDatabase({ databaseId, username: recipient.username }) + await this.test.userbase.signOut() + + // recipient signs in and checks if can read the database + await this.test.userbase.signIn({ username: recipient.username, password: recipient.password, rememberMe: 'none' }) + + // getDatabases() must be run before opening a database by its databaseId + await this.test.userbase.getDatabases() + + let changeHandlerCallCount = 0 + const changeHandler = function (items) { + expect(items, 'array passed to changeHandler').to.be.a('array') + expect(items, 'array passed to changeHandler').to.deep.equal([{ + itemId: testItemId, + item: testItem, + createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp } + }]) + + changeHandlerCallCount += 1 + } + + await this.test.userbase.openDatabase({ databaseId, changeHandler }) + + expect(changeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) + + // clean up + await this.test.userbase.deleteUser() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + await this.test.userbase.deleteUser() + }) + }) describe('Failure Tests', function () { @@ -1353,7 +1458,7 @@ describe('DB Sharing Tests', function () { await this.test.userbase.deleteUser() }) - it('Calling twice overwrites share token', async function () { + it('Calling twice with same permissions overwrites share token', async function () { const sender = await signUp(this.test.userbase) await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) @@ -1392,9 +1497,9 @@ describe('DB Sharing Tests', function () { await this.test.userbase.openDatabase({ shareToken: firstShareToken.shareToken, changeHandler }) throw new Error('should have failed') } catch (e) { - expect(e.name, 'error name').to.be.equal('ShareTokenExpired') - expect(e.message, 'error message').to.be.equal('Share token expired. The database owner has generated a new share token.') - expect(e.status, 'error status').to.be.equal(403) + expect(e.name, 'error name').to.be.equal('ShareTokenNotFound') + expect(e.message, 'error message').to.be.equal('Share token not found. Perhaps the database owner has generated a new share token.') + expect(e.status, 'error status').to.be.equal(404) } // clean up @@ -1402,6 +1507,249 @@ describe('DB Sharing Tests', function () { await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) await this.test.userbase.deleteUser() }) + + it('Calling twice with different permissions', async function () { + const sender = await signUp(this.test.userbase) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // sender gets 2 share tokens, a read only, and write + const readOnlyToken = await this.test.userbase.shareDatabase({ databaseName }) + const writeToken = await this.test.userbase.shareDatabase({ databaseName, readOnly: false }) + expect(readOnlyToken.shareToken, 'diff share tokens').to.not.eq(writeToken.shareToken) + + // sender inserts item into database + const testItem = 'hello world!' + const testItemId = 'test-id' + await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) + await this.test.userbase.signOut() + + // recipient signs up and checks if can read readOnly database, and write to write database + const recipient = await signUp(this.test.userbase) + + let changeHandlerCallCount = 0 + const changeHandler = function (items) { + expect(items, 'array passed to changeHandler').to.be.a('array') + expect(items, 'array passed to changeHandler').to.deep.equal([{ + itemId: testItemId, + item: testItem, + createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp } + }]) + + changeHandlerCallCount += 1 + } + + await this.test.userbase.openDatabase({ shareToken: readOnlyToken.shareToken, changeHandler }) + + expect(changeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) + + // sign out and sign back in to open with second token + await this.test.userbase.signOut() + await this.test.userbase.signIn({ username: recipient.username, password: recipient.password, rememberMe: 'none' }) + + const updatedTestItem = 'Hello, world!' + + let secondChandlerCallCount = 0 + const secondChandler = function (items) { + if (secondChandlerCallCount === 1) { + expect(items, 'array passed to changeHandler').to.be.a('array') + expect(items, 'array passed to changeHandler').to.deep.equal([{ + itemId: testItemId, + item: updatedTestItem, + createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp }, + updatedBy: { username: recipient.username, timestamp: items[0].updatedBy.timestamp } + }]) + } + + secondChandlerCallCount += 1 + } + + await this.test.userbase.openDatabase({ shareToken: writeToken.shareToken, changeHandler: secondChandler }) + await this.test.userbase.updateItem({ shareToken: writeToken.shareToken, item: updatedTestItem, itemId: testItemId }) + + expect(secondChandlerCallCount, 'second changeHandler called correct number of times').to.equal(2) + + // clean up + await this.test.userbase.deleteUser() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + await this.test.userbase.deleteUser() + }) + + it('Calling twice with same permissions overwrites share token, but does not affect existing share token with separte permissions', async function () { + const sender = await signUp(this.test.userbase) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // sender gets 3 share tokens, a write token, and 2 read-only tokens. Write token and 2nd read-only should work + const writeToken = await this.test.userbase.shareDatabase({ databaseName, readOnly: false }) + + const firstReadOnlyShareToken = await this.test.userbase.shareDatabase({ databaseName }) + const secondReadOnlyShareToken = await this.test.userbase.shareDatabase({ databaseName }) + expect(firstReadOnlyShareToken.shareToken, 'diff share tokens').to.not.eq(secondReadOnlyShareToken.shareToken) + + // sender inserts item into database + const testItem = 'hello world!' + const testItemId = 'test-id' + await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) + await this.test.userbase.signOut() + + // recipient signs up and checks if can read the database + const recipient = await signUp(this.test.userbase) + + let changeHandlerCallCount = 0 + const changeHandler = function (items) { + expect(items, 'array passed to changeHandler').to.be.a('array') + expect(items, 'array passed to changeHandler').to.deep.equal([{ + itemId: testItemId, + item: testItem, + createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp } + }]) + + changeHandlerCallCount += 1 + } + + await this.test.userbase.openDatabase({ shareToken: secondReadOnlyShareToken.shareToken, changeHandler }) + + expect(changeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) + + // sign out and sign back in to try with other tokens + await this.test.userbase.signOut() + await this.test.userbase.signIn({ username: recipient.username, password: recipient.password, rememberMe: 'none' }) + + // first read-only share token should not work + try { + await this.test.userbase.openDatabase({ shareToken: firstReadOnlyShareToken.shareToken, changeHandler }) + throw new Error('should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('ShareTokenNotFound') + expect(e.message, 'error message').to.be.equal('Share token not found. Perhaps the database owner has generated a new share token.') + expect(e.status, 'error status').to.be.equal(404) + } + + // write token should work + const updatedTestItem = 'Hello, world!' + + let secondChandlerCallCount = 0 + const secondChandler = function (items) { + if (secondChandlerCallCount === 1) { + expect(items, 'array passed to changeHandler').to.be.a('array') + expect(items, 'array passed to changeHandler').to.deep.equal([{ + itemId: testItemId, + item: updatedTestItem, + createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp }, + updatedBy: { username: recipient.username, timestamp: items[0].updatedBy.timestamp } + }]) + } + + secondChandlerCallCount += 1 + } + + await this.test.userbase.openDatabase({ shareToken: writeToken.shareToken, changeHandler: secondChandler }) + await this.test.userbase.updateItem({ shareToken: writeToken.shareToken, item: updatedTestItem, itemId: testItemId }) + + expect(secondChandlerCallCount, 'second changeHandler called correct number of times').to.equal(2) + + // clean up + await this.test.userbase.deleteUser() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + await this.test.userbase.deleteUser() + }) + + it('Sharing without opening first', async function () { + const recipient = await signUp(this.test.userbase) + const { verificationMessage } = await this.test.userbase.getVerificationMessage() + await this.test.userbase.signOut() + + const sender = await signUp(this.test.userbase) + await this.test.userbase.verifyUser({ verificationMessage }) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // sender inserts item into database + const testItem = 'hello world!' + const testItemId = 'test-id' + await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) + + // sign out, sign back in + await this.test.userbase.signOut() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + + // sender shares database without opening first + const { shareToken } = await this.test.userbase.shareDatabase({ databaseName }) + await this.test.userbase.signOut() + + // recipient signs in and checks if can read the database + await this.test.userbase.signIn({ username: recipient.username, password: recipient.password, rememberMe: 'none' }) + + let changeHandlerCallCount = 0 + const changeHandler = function (items) { + expect(items, 'array passed to changeHandler').to.be.a('array') + expect(items, 'array passed to changeHandler').to.deep.equal([{ + itemId: testItemId, + item: testItem, + createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp } + }]) + + changeHandlerCallCount += 1 + } + + await this.test.userbase.openDatabase({ shareToken, changeHandler }) + + expect(changeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) + + // clean up + await this.test.userbase.deleteUser() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + await this.test.userbase.deleteUser() + }) + + it('Share by databaseId without opening first', async function () { + const recipient = await signUp(this.test.userbase) + const { verificationMessage } = await this.test.userbase.getVerificationMessage() + await this.test.userbase.signOut() + + const sender = await signUp(this.test.userbase) + await this.test.userbase.verifyUser({ verificationMessage }) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // sender inserts item into database + const testItem = 'hello world!' + const testItemId = 'test-id' + await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) + + // get database's id + const { databases: [{ databaseId }] } = await this.test.userbase.getDatabases() + + // sign out, sign back in + await this.test.userbase.signOut() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + + // sender shares database with recipient without opening + const { shareToken } = await this.test.userbase.shareDatabase({ databaseId }) + await this.test.userbase.signOut() + + // recipient signs in and checks if can read the database + await this.test.userbase.signIn({ username: recipient.username, password: recipient.password, rememberMe: 'none' }) + + let changeHandlerCallCount = 0 + const changeHandler = function (items) { + expect(items, 'array passed to changeHandler').to.be.a('array') + expect(items, 'array passed to changeHandler').to.deep.equal([{ + itemId: testItemId, + item: testItem, + createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp } + }]) + + changeHandlerCallCount += 1 + } + + await this.test.userbase.openDatabase({ shareToken, changeHandler }) + + expect(changeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) + + // clean up + await this.test.userbase.deleteUser() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + await this.test.userbase.deleteUser() + }) + }) describe('Failure Tests', function () { From e13a04174df3826bcb6ce2849febc5cdad177646 Mon Sep 17 00:00:00 2001 From: Justin Berman Date: Wed, 16 Dec 2020 04:06:56 -0800 Subject: [PATCH 7/7] Separate share token tests into a new spec --- .../Database Sharing/share-database.spec.js | 876 ----------------- .../Database Sharing/share-token.spec.js | 908 ++++++++++++++++++ 2 files changed, 908 insertions(+), 876 deletions(-) create mode 100644 cypress/integration/Database Sharing/share-token.spec.js diff --git a/cypress/integration/Database Sharing/share-database.spec.js b/cypress/integration/Database Sharing/share-database.spec.js index 5d6a5b31..9208eb13 100644 --- a/cypress/integration/Database Sharing/share-database.spec.js +++ b/cypress/integration/Database Sharing/share-database.spec.js @@ -1310,880 +1310,4 @@ describe('DB Sharing Tests', function () { }) - describe('Share Database by retrieving share token', function () { - - describe('Sucess Tests', function () { - beforeEach(function () { beforeEachHook() }) - - it('Default', async function () { - const sender = await signUp(this.test.userbase) - await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) - - // sender gets share token - const { shareToken } = await this.test.userbase.shareDatabase({ databaseName }) - - // sender inserts item into database - const testItem = 'hello world!' - const testItemId = 'test-id' - await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) - await this.test.userbase.signOut() - - // recipient signs up and checks if can read the database with shareToken - await signUp(this.test.userbase) - - let changeHandlerCallCount = 0 - const changeHandler = function (items) { - expect(items, 'array passed to changeHandler').to.be.a('array') - expect(items, 'array passed to changeHandler').to.deep.equal([{ - itemId: testItemId, - item: testItem, - createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp } - }]) - - changeHandlerCallCount += 1 - } - - await this.test.userbase.openDatabase({ shareToken, changeHandler }) - - expect(changeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) - - // clean up - await this.test.userbase.deleteUser() - await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) - await this.test.userbase.deleteUser() - }) - - it('Share own database by databaseId', async function () { - const sender = await signUp(this.test.userbase) - await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) - - // get database's id - const { databases: [{ databaseId }] } = await this.test.userbase.getDatabases() - - // sender gets share token - const { shareToken } = await this.test.userbase.shareDatabase({ databaseId }) - - // sender inserts item into database - const testItem = 'hello world!' - const testItemId = 'test-id' - await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) - await this.test.userbase.signOut() - - // recipient signs up and checks if can read the database with shareToken - await signUp(this.test.userbase) - - // getDatabases() must be run before opening a database by its databaseId - await this.test.userbase.getDatabases() - - let changeHandlerCallCount = 0 - const changeHandler = function (items) { - expect(items, 'array passed to changeHandler').to.be.a('array') - expect(items, 'array passed to changeHandler').to.deep.equal([{ - itemId: testItemId, - item: testItem, - createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp } - }]) - - changeHandlerCallCount += 1 - } - - await this.test.userbase.openDatabase({ shareToken, changeHandler }) - - expect(changeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) - - // clean up - await this.test.userbase.deleteUser() - await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) - await this.test.userbase.deleteUser() - }) - - it('readOnly false', async function () { - const sender = await signUp(this.test.userbase) - await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) - - // sender gets share token - const { shareToken } = await this.test.userbase.shareDatabase({ databaseName, readOnly: false }) - await this.test.userbase.signOut() - - // recipient signs up and checks if can insert into the database - const recipient = await signUp(this.test.userbase) - - let recipientChangeHandlerCallCount = 0 - const recipientChangeHandler = function (items) { - expect(items, 'array passed to changeHandler').to.be.a('array') - - if (recipientChangeHandlerCallCount === 0) { - expect(items, 'array passed to changeHandler').to.deep.equal([]) - } else { - expect(items, 'array passed to changeHandler').to.deep.equal([{ - itemId: testItemId, - item: testItem, - createdBy: { username: recipient.username, timestamp: items[0].createdBy.timestamp } - }]) - } - - recipientChangeHandlerCallCount += 1 - } - - await this.test.userbase.openDatabase({ shareToken, changeHandler: recipientChangeHandler }) - - // recipient inserts item into database - const testItem = 'hello world!' - const testItemId = 'test-id' - await this.test.userbase.insertItem({ shareToken, item: testItem, itemId: testItemId }) - - expect(recipientChangeHandlerCallCount, 'changeHandler called correct number of times').to.equal(2) - - await this.test.userbase.deleteUser() - - // sender should be able to read the item too - await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) - - let senderChangeHandlerCallCount = 0 - const senderChangeHandler = function (items) { - expect(items, 'array passed to changeHandler').to.be.a('array') - expect(items, 'array passed to changeHandler').to.deep.equal([{ - itemId: testItemId, - item: testItem, - createdBy: { userDeleted: true, timestamp: items[0].createdBy.timestamp } - }]) - - senderChangeHandlerCallCount += 1 - } - await this.test.userbase.openDatabase({ databaseName, changeHandler: senderChangeHandler }) - - expect(senderChangeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) - - // clean up - await this.test.userbase.deleteUser() - }) - - it('Calling twice with same permissions overwrites share token', async function () { - const sender = await signUp(this.test.userbase) - await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) - - // sender gets 2 share tokens, only 2nd works - const firstShareToken = await this.test.userbase.shareDatabase({ databaseName }) - const { shareToken } = await this.test.userbase.shareDatabase({ databaseName }) - expect(firstShareToken.shareToken, 'diff share tokens').to.not.eq(shareToken) - - // sender inserts item into database - const testItem = 'hello world!' - const testItemId = 'test-id' - await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) - await this.test.userbase.signOut() - - // recipient signs up and checks if can read the database - await signUp(this.test.userbase) - - let changeHandlerCallCount = 0 - const changeHandler = function (items) { - expect(items, 'array passed to changeHandler').to.be.a('array') - expect(items, 'array passed to changeHandler').to.deep.equal([{ - itemId: testItemId, - item: testItem, - createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp } - }]) - - changeHandlerCallCount += 1 - } - - await this.test.userbase.openDatabase({ shareToken, changeHandler }) - - expect(changeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) - - // first share token should not work - try { - await this.test.userbase.openDatabase({ shareToken: firstShareToken.shareToken, changeHandler }) - throw new Error('should have failed') - } catch (e) { - expect(e.name, 'error name').to.be.equal('ShareTokenNotFound') - expect(e.message, 'error message').to.be.equal('Share token not found. Perhaps the database owner has generated a new share token.') - expect(e.status, 'error status').to.be.equal(404) - } - - // clean up - await this.test.userbase.deleteUser() - await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) - await this.test.userbase.deleteUser() - }) - - it('Calling twice with different permissions', async function () { - const sender = await signUp(this.test.userbase) - await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) - - // sender gets 2 share tokens, a read only, and write - const readOnlyToken = await this.test.userbase.shareDatabase({ databaseName }) - const writeToken = await this.test.userbase.shareDatabase({ databaseName, readOnly: false }) - expect(readOnlyToken.shareToken, 'diff share tokens').to.not.eq(writeToken.shareToken) - - // sender inserts item into database - const testItem = 'hello world!' - const testItemId = 'test-id' - await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) - await this.test.userbase.signOut() - - // recipient signs up and checks if can read readOnly database, and write to write database - const recipient = await signUp(this.test.userbase) - - let changeHandlerCallCount = 0 - const changeHandler = function (items) { - expect(items, 'array passed to changeHandler').to.be.a('array') - expect(items, 'array passed to changeHandler').to.deep.equal([{ - itemId: testItemId, - item: testItem, - createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp } - }]) - - changeHandlerCallCount += 1 - } - - await this.test.userbase.openDatabase({ shareToken: readOnlyToken.shareToken, changeHandler }) - - expect(changeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) - - // sign out and sign back in to open with second token - await this.test.userbase.signOut() - await this.test.userbase.signIn({ username: recipient.username, password: recipient.password, rememberMe: 'none' }) - - const updatedTestItem = 'Hello, world!' - - let secondChandlerCallCount = 0 - const secondChandler = function (items) { - if (secondChandlerCallCount === 1) { - expect(items, 'array passed to changeHandler').to.be.a('array') - expect(items, 'array passed to changeHandler').to.deep.equal([{ - itemId: testItemId, - item: updatedTestItem, - createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp }, - updatedBy: { username: recipient.username, timestamp: items[0].updatedBy.timestamp } - }]) - } - - secondChandlerCallCount += 1 - } - - await this.test.userbase.openDatabase({ shareToken: writeToken.shareToken, changeHandler: secondChandler }) - await this.test.userbase.updateItem({ shareToken: writeToken.shareToken, item: updatedTestItem, itemId: testItemId }) - - expect(secondChandlerCallCount, 'second changeHandler called correct number of times').to.equal(2) - - // clean up - await this.test.userbase.deleteUser() - await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) - await this.test.userbase.deleteUser() - }) - - it('Calling twice with same permissions overwrites share token, but does not affect existing share token with separte permissions', async function () { - const sender = await signUp(this.test.userbase) - await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) - - // sender gets 3 share tokens, a write token, and 2 read-only tokens. Write token and 2nd read-only should work - const writeToken = await this.test.userbase.shareDatabase({ databaseName, readOnly: false }) - - const firstReadOnlyShareToken = await this.test.userbase.shareDatabase({ databaseName }) - const secondReadOnlyShareToken = await this.test.userbase.shareDatabase({ databaseName }) - expect(firstReadOnlyShareToken.shareToken, 'diff share tokens').to.not.eq(secondReadOnlyShareToken.shareToken) - - // sender inserts item into database - const testItem = 'hello world!' - const testItemId = 'test-id' - await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) - await this.test.userbase.signOut() - - // recipient signs up and checks if can read the database - const recipient = await signUp(this.test.userbase) - - let changeHandlerCallCount = 0 - const changeHandler = function (items) { - expect(items, 'array passed to changeHandler').to.be.a('array') - expect(items, 'array passed to changeHandler').to.deep.equal([{ - itemId: testItemId, - item: testItem, - createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp } - }]) - - changeHandlerCallCount += 1 - } - - await this.test.userbase.openDatabase({ shareToken: secondReadOnlyShareToken.shareToken, changeHandler }) - - expect(changeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) - - // sign out and sign back in to try with other tokens - await this.test.userbase.signOut() - await this.test.userbase.signIn({ username: recipient.username, password: recipient.password, rememberMe: 'none' }) - - // first read-only share token should not work - try { - await this.test.userbase.openDatabase({ shareToken: firstReadOnlyShareToken.shareToken, changeHandler }) - throw new Error('should have failed') - } catch (e) { - expect(e.name, 'error name').to.be.equal('ShareTokenNotFound') - expect(e.message, 'error message').to.be.equal('Share token not found. Perhaps the database owner has generated a new share token.') - expect(e.status, 'error status').to.be.equal(404) - } - - // write token should work - const updatedTestItem = 'Hello, world!' - - let secondChandlerCallCount = 0 - const secondChandler = function (items) { - if (secondChandlerCallCount === 1) { - expect(items, 'array passed to changeHandler').to.be.a('array') - expect(items, 'array passed to changeHandler').to.deep.equal([{ - itemId: testItemId, - item: updatedTestItem, - createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp }, - updatedBy: { username: recipient.username, timestamp: items[0].updatedBy.timestamp } - }]) - } - - secondChandlerCallCount += 1 - } - - await this.test.userbase.openDatabase({ shareToken: writeToken.shareToken, changeHandler: secondChandler }) - await this.test.userbase.updateItem({ shareToken: writeToken.shareToken, item: updatedTestItem, itemId: testItemId }) - - expect(secondChandlerCallCount, 'second changeHandler called correct number of times').to.equal(2) - - // clean up - await this.test.userbase.deleteUser() - await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) - await this.test.userbase.deleteUser() - }) - - it('Sharing without opening first', async function () { - const recipient = await signUp(this.test.userbase) - const { verificationMessage } = await this.test.userbase.getVerificationMessage() - await this.test.userbase.signOut() - - const sender = await signUp(this.test.userbase) - await this.test.userbase.verifyUser({ verificationMessage }) - await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) - - // sender inserts item into database - const testItem = 'hello world!' - const testItemId = 'test-id' - await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) - - // sign out, sign back in - await this.test.userbase.signOut() - await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) - - // sender shares database without opening first - const { shareToken } = await this.test.userbase.shareDatabase({ databaseName }) - await this.test.userbase.signOut() - - // recipient signs in and checks if can read the database - await this.test.userbase.signIn({ username: recipient.username, password: recipient.password, rememberMe: 'none' }) - - let changeHandlerCallCount = 0 - const changeHandler = function (items) { - expect(items, 'array passed to changeHandler').to.be.a('array') - expect(items, 'array passed to changeHandler').to.deep.equal([{ - itemId: testItemId, - item: testItem, - createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp } - }]) - - changeHandlerCallCount += 1 - } - - await this.test.userbase.openDatabase({ shareToken, changeHandler }) - - expect(changeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) - - // clean up - await this.test.userbase.deleteUser() - await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) - await this.test.userbase.deleteUser() - }) - - it('Share by databaseId without opening first', async function () { - const recipient = await signUp(this.test.userbase) - const { verificationMessage } = await this.test.userbase.getVerificationMessage() - await this.test.userbase.signOut() - - const sender = await signUp(this.test.userbase) - await this.test.userbase.verifyUser({ verificationMessage }) - await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) - - // sender inserts item into database - const testItem = 'hello world!' - const testItemId = 'test-id' - await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) - - // get database's id - const { databases: [{ databaseId }] } = await this.test.userbase.getDatabases() - - // sign out, sign back in - await this.test.userbase.signOut() - await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) - - // sender shares database with recipient without opening - const { shareToken } = await this.test.userbase.shareDatabase({ databaseId }) - await this.test.userbase.signOut() - - // recipient signs in and checks if can read the database - await this.test.userbase.signIn({ username: recipient.username, password: recipient.password, rememberMe: 'none' }) - - let changeHandlerCallCount = 0 - const changeHandler = function (items) { - expect(items, 'array passed to changeHandler').to.be.a('array') - expect(items, 'array passed to changeHandler').to.deep.equal([{ - itemId: testItemId, - item: testItem, - createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp } - }]) - - changeHandlerCallCount += 1 - } - - await this.test.userbase.openDatabase({ shareToken, changeHandler }) - - expect(changeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) - - // clean up - await this.test.userbase.deleteUser() - await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) - await this.test.userbase.deleteUser() - }) - - }) - - describe('Failure Tests', function () { - beforeEach(function () { beforeEachHook() }) - - it('Database is read only', async function () { - const sender = await signUp(this.test.userbase) - await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) - - const testItem = 'hello world!' - const testItemId = 'test-id' - await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) - - // sender gets share token - const { shareToken } = await this.test.userbase.shareDatabase({ databaseName }) - await this.test.userbase.signOut() - - // recipient signs up and checks if can insert into the database - await signUp(this.test.userbase) - await this.test.userbase.openDatabase({ shareToken, changeHandler: () => { } }) - - const expectedError = (e) => { - expect(e.name, 'error name').to.be.equal('DatabaseIsReadOnly') - expect(e.message, 'error message').to.be.equal('Database is read only. Must have permission to write to database.') - expect(e.status, 'error status').to.be.equal(403) - } - - // recipient tries to insert, update, delete, putTransaction, uploadFile into database - try { - await this.test.userbase.insertItem({ shareToken, item: testItem }) - throw new Error('Should have failed') - } catch (e) { - expectedError(e) - } - - try { - await this.test.userbase.updateItem({ shareToken, item: testItem, itemId: testItemId }) - throw new Error('Should have failed') - } catch (e) { - expectedError(e) - } - - try { - await this.test.userbase.deleteItem({ shareToken, item: testItem, itemId: testItemId }) - throw new Error('Should have failed') - } catch (e) { - expectedError(e) - } - - try { - await this.test.userbase.putTransaction({ shareToken, operations: [{ command: 'Delete', itemId: testItemId }] }) - throw new Error('Should have failed') - } catch (e) { - expectedError(e) - } - - try { - const testFileName = 'test-file-name.txt' - const testFileType = 'text/plain' - const testFile = new this.test.win.File([1], testFileName, { type: testFileType }) - await this.test.userbase.uploadFile({ shareToken, file: testFile, itemId: testItemId }) - throw new Error('Should have failed') - } catch (e) { - expectedError(e) - } - - // clean up - await this.test.userbase.deleteUser() - await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) - await this.test.userbase.deleteUser() - }) - - it('Resharing not allowed', async function () { - const recipient = await signUp(this.test.userbase) - const { verificationMessage } = await this.test.userbase.getVerificationMessage() - await this.test.userbase.signOut() - - const sender = await signUp(this.test.userbase) - await this.test.userbase.verifyUser({ verificationMessage }) - await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) - - // sender shares database with recipient - await this.test.userbase.shareDatabase({ databaseName, username: recipient.username }) - await this.test.userbase.signOut() - - // recipient signs in and checks if can get a new share token - await this.test.userbase.signIn({ username: recipient.username, password: recipient.password, rememberMe: 'none' }) - - // recipient must find the database's databaseId using getDatabases() result - const { databases } = await this.test.userbase.getDatabases() - const db = databases[0] - const { databaseId } = db - - try { - await this.test.userbase.shareDatabase({ databaseId }) - throw new Error('should have failed') - } catch (e) { - expect(e.name, 'error name').to.be.equal('ResharingNotAllowed') - expect(e.message, 'error message').to.be.equal('Resharing not allowed. Only the owner can generate a share token.') - expect(e.status, 'error status').to.be.equal(403) - } - - // clean up - await this.test.userbase.deleteUser() - await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) - await this.test.userbase.deleteUser() - }) - - it('Share token invalid', async function () { - await signUp(this.test.userbase) - - try { - await this.test.userbase.openDatabase({ shareToken: 'a', changeHandler: () => { } }) - throw new Error('Should have failed') - } catch (e) { - expect(e.name, 'error name').to.be.equal('ShareTokenInvalid') - expect(e.message, 'error message').to.be.equal('Share token invalid.') - expect(e.status, 'error status').to.be.equal(400) - } - - // clean up - await this.test.userbase.deleteUser() - }) - - it('Share token not allowed - passing share token to shareDatabase', async function () { - const sender = await signUp(this.test.userbase) - await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) - - // sender gets share token - const { shareToken } = await this.test.userbase.shareDatabase({ databaseName }) - await this.test.userbase.signOut() - - // recipient signs up and tries to reshare share token - await signUp(this.test.userbase) - - try { - await this.test.userbase.shareDatabase({ shareToken }) - throw new Error('Should have failed') - } catch (e) { - expect(e.name, 'error name').to.be.equal('ShareTokenNotAllowed') - expect(e.message, 'error message').to.be.equal('Share token not allowed.') - expect(e.status, 'error status').to.be.equal(400) - } - - // clean up - await this.test.userbase.deleteUser() - await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) - await this.test.userbase.deleteUser() - }) - - it('Share token not allowed - passing database name and share token to shareDatabase', async function () { - await signUp(this.test.userbase) - await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) - - try { - await this.test.userbase.shareDatabase({ databaseName, shareToken: '' }) - throw new Error('Should have failed') - } catch (e) { - expect(e.name, 'error name').to.be.equal('ShareTokenNotAllowed') - expect(e.message, 'error message').to.be.equal('Share token not allowed.') - expect(e.status, 'error status').to.be.equal(400) - } - - // clean up - await this.test.userbase.deleteUser() - }) - - it('Share token not allowed - passing database ID and share token to shareDatabase', async function () { - await signUp(this.test.userbase) - await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) - - // get database's id - const { databases: [{ databaseId }] } = await this.test.userbase.getDatabases() - - try { - await this.test.userbase.shareDatabase({ databaseId, shareToken: '' }) - throw new Error('Should have failed') - } catch (e) { - expect(e.name, 'error name').to.be.equal('ShareTokenNotAllowed') - expect(e.message, 'error message').to.be.equal('Share token not allowed.') - expect(e.status, 'error status').to.be.equal(400) - } - - // clean up - await this.test.userbase.deleteUser() - }) - - it('Owner opening with share token not allowed', async function () { - const sender = await signUp(this.test.userbase) - await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) - - // sender gets share token, then tries to open with it - const { shareToken } = await this.test.userbase.shareDatabase({ databaseName }) - await this.test.userbase.signOut() - await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) - - // sender tries to share database with self - try { - await this.test.userbase.openDatabase({ shareToken, changeHandler: () => { } }) - throw new Error('Should have failed') - } catch (e) { - expect(e.name, 'error name').to.be.equal('ShareTokenNotAllowedForOwnDatabase') - expect(e.message, 'error message').to.be.equal("Tried to open the user's own database using its shareToken rather than its databaseName. The shareToken should only be used to open databases shared from other users.") - expect(e.status, 'error status').to.be.equal(403) - } - - // clean up - await this.test.userbase.deleteUser() - }) - - it('Database not found - does not exist', async function () { - // sign up sender - await signUp(this.test.userbase) - - // sender tries to share non-existent database - try { - await this.test.userbase.shareDatabase({ databaseName }) - throw new Error('Should have failed') - } catch (e) { - expect(e.name, 'error name').to.be.equal('DatabaseNotFound') - expect(e.message, 'error message').to.be.equal('Database not found. Find available databases using getDatabases().') - expect(e.status, 'error status').to.be.equal(404) - } - - // clean up - await this.test.userbase.deleteUser() - }) - - it('Database name missing', async function () { - await signUp(this.test.userbase) - - try { - await this.test.userbase.shareDatabase({}) - throw new Error('Should have failed') - } catch (e) { - expect(e.name, 'error name').to.be.equal('DatabaseNameMissing') - expect(e.message, 'error message').to.be.equal('Database name missing.') - expect(e.status, 'error status').to.be.equal(400) - } - - // clean up - await this.test.userbase.deleteUser() - }) - - it('Database name must be string', async function () { - await signUp(this.test.userbase) - - try { - await this.test.userbase.shareDatabase({ databaseName: 1 }) - throw new Error('Should have failed') - } catch (e) { - expect(e.name, 'error name').to.be.equal('DatabaseNameMustBeString') - expect(e.message, 'error message').to.be.equal('Database name must be a string.') - expect(e.status, 'error status').to.be.equal(400) - } - - // clean up - await this.test.userbase.deleteUser() - }) - - it('Database name cannot be blank', async function () { - await signUp(this.test.userbase) - - try { - await this.test.userbase.shareDatabase({ databaseName: '' }) - throw new Error('Should have failed') - } catch (e) { - expect(e.name, 'error name').to.be.equal('DatabaseNameCannotBeBlank') - expect(e.message, 'error message').to.be.equal('Database name cannot be blank.') - expect(e.status, 'error status').to.be.equal(400) - } - - // clean up - await this.test.userbase.deleteUser() - }) - - it('Database name too long', async function () { - await signUp(this.test.userbase) - - try { - await this.test.userbase.shareDatabase({ databaseName: 'a'.repeat(101) }) - throw new Error('Should have failed') - } catch (e) { - expect(e.name, 'error name').to.equal('DatabaseNameTooLong') - expect(e.message, 'error message').to.equal('Database name cannot be more than 100 characters.') - expect(e.status, 'error status').to.equal(400) - } - - // clean up - await this.test.userbase.deleteUser() - }) - - it('Database name restricted', async function () { - const verifiedUsersDatabaseName = '__userbase_verified_users' - - await signUp(this.test.userbase) - - try { - await this.test.userbase.shareDatabase({ databaseName: verifiedUsersDatabaseName }) - throw new Error('Should have failed') - } catch (e) { - expect(e.name, 'error name').to.equal('DatabaseNameRestricted') - expect(e.message, 'error message').to.equal(`Database name '${verifiedUsersDatabaseName}' is restricted. It is used internally by userbase-js.`) - expect(e.status, 'error status').to.equal(403) - } - - // clean up - await this.test.userbase.deleteUser() - }) - - it('Database id not allowed', async function () { - await signUp(this.test.userbase) - - try { - await this.test.userbase.shareDatabase({ databaseId: 'abc', databaseName }) - throw new Error('Should have failed') - } catch (e) { - expect(e.name, 'error name').to.be.equal('DatabaseIdNotAllowed') - expect(e.message, 'error message').to.be.equal('Database id not allowed. Cannot provide both databaseName and databaseId, can only provide one.') - expect(e.status, 'error status').to.be.equal(400) - } - - // clean up - await this.test.userbase.deleteUser() - }) - - it('Database id must be string', async function () { - await signUp(this.test.userbase) - - try { - await this.test.userbase.shareDatabase({ databaseId: 1 }) - throw new Error('Should have failed') - } catch (e) { - expect(e.name, 'error name').to.equal('DatabaseIdMustBeString') - expect(e.message, 'error message').to.equal('Database id must be a string.') - expect(e.status, 'error status').to.equal(400) - } - - // clean up - await this.test.userbase.deleteUser() - }) - - it('Database id cannot be blank', async function () { - await signUp(this.test.userbase) - - try { - await this.test.userbase.shareDatabase({ databaseId: '' }) - throw new Error('Should have failed') - } catch (e) { - expect(e.name, 'error name').to.be.equal('DatabaseIdCannotBeBlank') - expect(e.message, 'error message').to.be.equal('Database id cannot be blank.') - expect(e.status, 'error status').to.be.equal(400) - } - - // clean up - await this.test.userbase.deleteUser() - }) - - it('Database id invalid length', async function () { - await signUp(this.test.userbase) - - try { - await this.test.userbase.shareDatabase({ databaseId: 'abc' }) - throw new Error('Should have failed') - } catch (e) { - expect(e.name, 'error name').to.be.equal('DatabaseIdInvalidLength') - expect(e.message, 'error message').to.be.equal('Database id invalid length. Must be 36 characters.') - expect(e.status, 'error status').to.be.equal(400) - } - - // clean up - await this.test.userbase.deleteUser() - }) - - it('Read Only must be boolean', async function () { - await signUp(this.test.userbase) - - try { - await this.test.userbase.shareDatabase({ databaseName, readOnly: 'not boolean' }) - throw new Error('Should have failed') - } catch (e) { - expect(e.name, 'error name').to.be.equal('ReadOnlyMustBeBoolean') - expect(e.message, 'error message').to.be.equal('Read only value must be a boolean.') - expect(e.status, 'error status').to.be.equal(400) - } - - // clean up - await this.test.userbase.deleteUser() - }) - - it('Resharing allowed param not allowed', async function () { - await signUp(this.test.userbase) - - try { - await this.test.userbase.shareDatabase({ databaseName, resharingAllowed: true }) - throw new Error('Should have failed') - } catch (e) { - expect(e.name, 'error name').to.be.equal('ResharingAllowedParamNotAllowed') - expect(e.message, 'error message').to.be.equal('Resharing allowed parameter not allowed when retrieving a share token.') - expect(e.status, 'error status').to.be.equal(400) - } - - // clean up - await this.test.userbase.deleteUser() - }) - - it('Require verified param not necessary', async function () { - await signUp(this.test.userbase) - - try { - await this.test.userbase.shareDatabase({ databaseName, requireVerified: true }) - throw new Error('Should have failed') - } catch (e) { - expect(e.name, 'error name').to.be.equal('RequireVerifiedParamNotNecessary') - expect(e.message, 'error message').to.be.equal('Require verified parameter not necessary when sharing database without a username.') - expect(e.status, 'error status').to.be.equal(400) - } - - // clean up - await this.test.userbase.deleteUser() - }) - - it('User not signed in', async function () { - try { - await this.test.userbase.shareDatabase({ databaseName }) - throw new Error('Should have failed') - } catch (e) { - expect(e.name, 'error name').to.be.equal('UserNotSignedIn') - expect(e.message, 'error message').to.be.equal('Not signed in.') - expect(e.status, 'error status').to.be.equal(400) - } - }) - - }) - - }) }) diff --git a/cypress/integration/Database Sharing/share-token.spec.js b/cypress/integration/Database Sharing/share-token.spec.js new file mode 100644 index 00000000..5410b2cf --- /dev/null +++ b/cypress/integration/Database Sharing/share-token.spec.js @@ -0,0 +1,908 @@ +import { getRandomString } from '../../support/utils' + +const beforeEachHook = function () { + cy.visit('./cypress/integration/index.html').then(async function (win) { + expect(win).to.have.property('userbase') + const userbase = win.userbase + this.currentTest.userbase = userbase + this.currentTest.win = win + + const { appId, endpoint } = Cypress.env() + win._userbaseEndpoint = endpoint + userbase.init({ appId }) + }) +} + +const signUp = async (userbase) => { + const username = 'test-user-' + getRandomString() + const password = getRandomString() + + await userbase.signUp({ + username, + password, + rememberMe: 'none' + }) + + return { username, password } +} + +describe('DB Sharing Tests', function () { + const databaseName = 'test-db' + + describe('Share Database by retrieving share token', function () { + + describe('Sucess Tests', function () { + beforeEach(function () { beforeEachHook() }) + + it('Default', async function () { + const sender = await signUp(this.test.userbase) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // sender gets share token + const { shareToken } = await this.test.userbase.shareDatabase({ databaseName }) + + // sender inserts item into database + const testItem = 'hello world!' + const testItemId = 'test-id' + await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) + await this.test.userbase.signOut() + + // recipient signs up and checks if can read the database with shareToken + await signUp(this.test.userbase) + + let changeHandlerCallCount = 0 + const changeHandler = function (items) { + expect(items, 'array passed to changeHandler').to.be.a('array') + expect(items, 'array passed to changeHandler').to.deep.equal([{ + itemId: testItemId, + item: testItem, + createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp } + }]) + + changeHandlerCallCount += 1 + } + + await this.test.userbase.openDatabase({ shareToken, changeHandler }) + + expect(changeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) + + // clean up + await this.test.userbase.deleteUser() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + await this.test.userbase.deleteUser() + }) + + it('Share own database by databaseId', async function () { + const sender = await signUp(this.test.userbase) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // get database's id + const { databases: [{ databaseId }] } = await this.test.userbase.getDatabases() + + // sender gets share token + const { shareToken } = await this.test.userbase.shareDatabase({ databaseId }) + + // sender inserts item into database + const testItem = 'hello world!' + const testItemId = 'test-id' + await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) + await this.test.userbase.signOut() + + // recipient signs up and checks if can read the database with shareToken + await signUp(this.test.userbase) + + // getDatabases() must be run before opening a database by its databaseId + await this.test.userbase.getDatabases() + + let changeHandlerCallCount = 0 + const changeHandler = function (items) { + expect(items, 'array passed to changeHandler').to.be.a('array') + expect(items, 'array passed to changeHandler').to.deep.equal([{ + itemId: testItemId, + item: testItem, + createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp } + }]) + + changeHandlerCallCount += 1 + } + + await this.test.userbase.openDatabase({ shareToken, changeHandler }) + + expect(changeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) + + // clean up + await this.test.userbase.deleteUser() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + await this.test.userbase.deleteUser() + }) + + it('readOnly false', async function () { + const sender = await signUp(this.test.userbase) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // sender gets share token + const { shareToken } = await this.test.userbase.shareDatabase({ databaseName, readOnly: false }) + await this.test.userbase.signOut() + + // recipient signs up and checks if can insert into the database + const recipient = await signUp(this.test.userbase) + + let recipientChangeHandlerCallCount = 0 + const recipientChangeHandler = function (items) { + expect(items, 'array passed to changeHandler').to.be.a('array') + + if (recipientChangeHandlerCallCount === 0) { + expect(items, 'array passed to changeHandler').to.deep.equal([]) + } else { + expect(items, 'array passed to changeHandler').to.deep.equal([{ + itemId: testItemId, + item: testItem, + createdBy: { username: recipient.username, timestamp: items[0].createdBy.timestamp } + }]) + } + + recipientChangeHandlerCallCount += 1 + } + + await this.test.userbase.openDatabase({ shareToken, changeHandler: recipientChangeHandler }) + + // recipient inserts item into database + const testItem = 'hello world!' + const testItemId = 'test-id' + await this.test.userbase.insertItem({ shareToken, item: testItem, itemId: testItemId }) + + expect(recipientChangeHandlerCallCount, 'changeHandler called correct number of times').to.equal(2) + + await this.test.userbase.deleteUser() + + // sender should be able to read the item too + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + + let senderChangeHandlerCallCount = 0 + const senderChangeHandler = function (items) { + expect(items, 'array passed to changeHandler').to.be.a('array') + expect(items, 'array passed to changeHandler').to.deep.equal([{ + itemId: testItemId, + item: testItem, + createdBy: { userDeleted: true, timestamp: items[0].createdBy.timestamp } + }]) + + senderChangeHandlerCallCount += 1 + } + await this.test.userbase.openDatabase({ databaseName, changeHandler: senderChangeHandler }) + + expect(senderChangeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Calling twice with same permissions overwrites share token', async function () { + const sender = await signUp(this.test.userbase) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // sender gets 2 share tokens, only 2nd works + const firstShareToken = await this.test.userbase.shareDatabase({ databaseName }) + const { shareToken } = await this.test.userbase.shareDatabase({ databaseName }) + expect(firstShareToken.shareToken, 'diff share tokens').to.not.eq(shareToken) + + // sender inserts item into database + const testItem = 'hello world!' + const testItemId = 'test-id' + await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) + await this.test.userbase.signOut() + + // recipient signs up and checks if can read the database + await signUp(this.test.userbase) + + let changeHandlerCallCount = 0 + const changeHandler = function (items) { + expect(items, 'array passed to changeHandler').to.be.a('array') + expect(items, 'array passed to changeHandler').to.deep.equal([{ + itemId: testItemId, + item: testItem, + createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp } + }]) + + changeHandlerCallCount += 1 + } + + await this.test.userbase.openDatabase({ shareToken, changeHandler }) + + expect(changeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) + + // first share token should not work + try { + await this.test.userbase.openDatabase({ shareToken: firstShareToken.shareToken, changeHandler }) + throw new Error('should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('ShareTokenNotFound') + expect(e.message, 'error message').to.be.equal('Share token not found. Perhaps the database owner has generated a new share token.') + expect(e.status, 'error status').to.be.equal(404) + } + + // clean up + await this.test.userbase.deleteUser() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + await this.test.userbase.deleteUser() + }) + + it('Calling twice with different permissions', async function () { + const sender = await signUp(this.test.userbase) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // sender gets 2 share tokens, a read only, and write + const readOnlyToken = await this.test.userbase.shareDatabase({ databaseName }) + const writeToken = await this.test.userbase.shareDatabase({ databaseName, readOnly: false }) + expect(readOnlyToken.shareToken, 'diff share tokens').to.not.eq(writeToken.shareToken) + + // sender inserts item into database + const testItem = 'hello world!' + const testItemId = 'test-id' + await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) + await this.test.userbase.signOut() + + // recipient signs up and checks if can read readOnly database, and write to write database + const recipient = await signUp(this.test.userbase) + + let changeHandlerCallCount = 0 + const changeHandler = function (items) { + expect(items, 'array passed to changeHandler').to.be.a('array') + expect(items, 'array passed to changeHandler').to.deep.equal([{ + itemId: testItemId, + item: testItem, + createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp } + }]) + + changeHandlerCallCount += 1 + } + + await this.test.userbase.openDatabase({ shareToken: readOnlyToken.shareToken, changeHandler }) + + expect(changeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) + + // sign out and sign back in to open with second token + await this.test.userbase.signOut() + await this.test.userbase.signIn({ username: recipient.username, password: recipient.password, rememberMe: 'none' }) + + const updatedTestItem = 'Hello, world!' + + let secondChandlerCallCount = 0 + const secondChandler = function (items) { + if (secondChandlerCallCount === 1) { + expect(items, 'array passed to changeHandler').to.be.a('array') + expect(items, 'array passed to changeHandler').to.deep.equal([{ + itemId: testItemId, + item: updatedTestItem, + createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp }, + updatedBy: { username: recipient.username, timestamp: items[0].updatedBy.timestamp } + }]) + } + + secondChandlerCallCount += 1 + } + + await this.test.userbase.openDatabase({ shareToken: writeToken.shareToken, changeHandler: secondChandler }) + await this.test.userbase.updateItem({ shareToken: writeToken.shareToken, item: updatedTestItem, itemId: testItemId }) + + expect(secondChandlerCallCount, 'second changeHandler called correct number of times').to.equal(2) + + // clean up + await this.test.userbase.deleteUser() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + await this.test.userbase.deleteUser() + }) + + it('Calling twice with same permissions overwrites share token, but does not affect existing share token with separte permissions', async function () { + const sender = await signUp(this.test.userbase) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // sender gets 3 share tokens, a write token, and 2 read-only tokens. Write token and 2nd read-only should work + const writeToken = await this.test.userbase.shareDatabase({ databaseName, readOnly: false }) + + const firstReadOnlyShareToken = await this.test.userbase.shareDatabase({ databaseName }) + const secondReadOnlyShareToken = await this.test.userbase.shareDatabase({ databaseName }) + expect(firstReadOnlyShareToken.shareToken, 'diff share tokens').to.not.eq(secondReadOnlyShareToken.shareToken) + + // sender inserts item into database + const testItem = 'hello world!' + const testItemId = 'test-id' + await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) + await this.test.userbase.signOut() + + // recipient signs up and checks if can read the database + const recipient = await signUp(this.test.userbase) + + let changeHandlerCallCount = 0 + const changeHandler = function (items) { + expect(items, 'array passed to changeHandler').to.be.a('array') + expect(items, 'array passed to changeHandler').to.deep.equal([{ + itemId: testItemId, + item: testItem, + createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp } + }]) + + changeHandlerCallCount += 1 + } + + await this.test.userbase.openDatabase({ shareToken: secondReadOnlyShareToken.shareToken, changeHandler }) + + expect(changeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) + + // sign out and sign back in to try with other tokens + await this.test.userbase.signOut() + await this.test.userbase.signIn({ username: recipient.username, password: recipient.password, rememberMe: 'none' }) + + // first read-only share token should not work + try { + await this.test.userbase.openDatabase({ shareToken: firstReadOnlyShareToken.shareToken, changeHandler }) + throw new Error('should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('ShareTokenNotFound') + expect(e.message, 'error message').to.be.equal('Share token not found. Perhaps the database owner has generated a new share token.') + expect(e.status, 'error status').to.be.equal(404) + } + + // write token should work + const updatedTestItem = 'Hello, world!' + + let secondChandlerCallCount = 0 + const secondChandler = function (items) { + if (secondChandlerCallCount === 1) { + expect(items, 'array passed to changeHandler').to.be.a('array') + expect(items, 'array passed to changeHandler').to.deep.equal([{ + itemId: testItemId, + item: updatedTestItem, + createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp }, + updatedBy: { username: recipient.username, timestamp: items[0].updatedBy.timestamp } + }]) + } + + secondChandlerCallCount += 1 + } + + await this.test.userbase.openDatabase({ shareToken: writeToken.shareToken, changeHandler: secondChandler }) + await this.test.userbase.updateItem({ shareToken: writeToken.shareToken, item: updatedTestItem, itemId: testItemId }) + + expect(secondChandlerCallCount, 'second changeHandler called correct number of times').to.equal(2) + + // clean up + await this.test.userbase.deleteUser() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + await this.test.userbase.deleteUser() + }) + + it('Sharing without opening first', async function () { + const recipient = await signUp(this.test.userbase) + const { verificationMessage } = await this.test.userbase.getVerificationMessage() + await this.test.userbase.signOut() + + const sender = await signUp(this.test.userbase) + await this.test.userbase.verifyUser({ verificationMessage }) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // sender inserts item into database + const testItem = 'hello world!' + const testItemId = 'test-id' + await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) + + // sign out, sign back in + await this.test.userbase.signOut() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + + // sender shares database without opening first + const { shareToken } = await this.test.userbase.shareDatabase({ databaseName }) + await this.test.userbase.signOut() + + // recipient signs in and checks if can read the database + await this.test.userbase.signIn({ username: recipient.username, password: recipient.password, rememberMe: 'none' }) + + let changeHandlerCallCount = 0 + const changeHandler = function (items) { + expect(items, 'array passed to changeHandler').to.be.a('array') + expect(items, 'array passed to changeHandler').to.deep.equal([{ + itemId: testItemId, + item: testItem, + createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp } + }]) + + changeHandlerCallCount += 1 + } + + await this.test.userbase.openDatabase({ shareToken, changeHandler }) + + expect(changeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) + + // clean up + await this.test.userbase.deleteUser() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + await this.test.userbase.deleteUser() + }) + + it('Share by databaseId without opening first', async function () { + const recipient = await signUp(this.test.userbase) + const { verificationMessage } = await this.test.userbase.getVerificationMessage() + await this.test.userbase.signOut() + + const sender = await signUp(this.test.userbase) + await this.test.userbase.verifyUser({ verificationMessage }) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // sender inserts item into database + const testItem = 'hello world!' + const testItemId = 'test-id' + await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) + + // get database's id + const { databases: [{ databaseId }] } = await this.test.userbase.getDatabases() + + // sign out, sign back in + await this.test.userbase.signOut() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + + // sender shares database with recipient without opening + const { shareToken } = await this.test.userbase.shareDatabase({ databaseId }) + await this.test.userbase.signOut() + + // recipient signs in and checks if can read the database + await this.test.userbase.signIn({ username: recipient.username, password: recipient.password, rememberMe: 'none' }) + + let changeHandlerCallCount = 0 + const changeHandler = function (items) { + expect(items, 'array passed to changeHandler').to.be.a('array') + expect(items, 'array passed to changeHandler').to.deep.equal([{ + itemId: testItemId, + item: testItem, + createdBy: { username: sender.username, timestamp: items[0].createdBy.timestamp } + }]) + + changeHandlerCallCount += 1 + } + + await this.test.userbase.openDatabase({ shareToken, changeHandler }) + + expect(changeHandlerCallCount, 'changeHandler called correct number of times').to.equal(1) + + // clean up + await this.test.userbase.deleteUser() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + await this.test.userbase.deleteUser() + }) + + }) + + describe('Failure Tests', function () { + beforeEach(function () { beforeEachHook() }) + + it('Database is read only', async function () { + const sender = await signUp(this.test.userbase) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + const testItem = 'hello world!' + const testItemId = 'test-id' + await this.test.userbase.insertItem({ databaseName, item: testItem, itemId: testItemId }) + + // sender gets share token + const { shareToken } = await this.test.userbase.shareDatabase({ databaseName }) + await this.test.userbase.signOut() + + // recipient signs up and checks if can insert into the database + await signUp(this.test.userbase) + await this.test.userbase.openDatabase({ shareToken, changeHandler: () => { } }) + + const expectedError = (e) => { + expect(e.name, 'error name').to.be.equal('DatabaseIsReadOnly') + expect(e.message, 'error message').to.be.equal('Database is read only. Must have permission to write to database.') + expect(e.status, 'error status').to.be.equal(403) + } + + // recipient tries to insert, update, delete, putTransaction, uploadFile into database + try { + await this.test.userbase.insertItem({ shareToken, item: testItem }) + throw new Error('Should have failed') + } catch (e) { + expectedError(e) + } + + try { + await this.test.userbase.updateItem({ shareToken, item: testItem, itemId: testItemId }) + throw new Error('Should have failed') + } catch (e) { + expectedError(e) + } + + try { + await this.test.userbase.deleteItem({ shareToken, item: testItem, itemId: testItemId }) + throw new Error('Should have failed') + } catch (e) { + expectedError(e) + } + + try { + await this.test.userbase.putTransaction({ shareToken, operations: [{ command: 'Delete', itemId: testItemId }] }) + throw new Error('Should have failed') + } catch (e) { + expectedError(e) + } + + try { + const testFileName = 'test-file-name.txt' + const testFileType = 'text/plain' + const testFile = new this.test.win.File([1], testFileName, { type: testFileType }) + await this.test.userbase.uploadFile({ shareToken, file: testFile, itemId: testItemId }) + throw new Error('Should have failed') + } catch (e) { + expectedError(e) + } + + // clean up + await this.test.userbase.deleteUser() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + await this.test.userbase.deleteUser() + }) + + it('Resharing not allowed', async function () { + const recipient = await signUp(this.test.userbase) + const { verificationMessage } = await this.test.userbase.getVerificationMessage() + await this.test.userbase.signOut() + + const sender = await signUp(this.test.userbase) + await this.test.userbase.verifyUser({ verificationMessage }) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // sender shares database with recipient + await this.test.userbase.shareDatabase({ databaseName, username: recipient.username }) + await this.test.userbase.signOut() + + // recipient signs in and checks if can get a new share token + await this.test.userbase.signIn({ username: recipient.username, password: recipient.password, rememberMe: 'none' }) + + // recipient must find the database's databaseId using getDatabases() result + const { databases } = await this.test.userbase.getDatabases() + const db = databases[0] + const { databaseId } = db + + try { + await this.test.userbase.shareDatabase({ databaseId }) + throw new Error('should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('ResharingNotAllowed') + expect(e.message, 'error message').to.be.equal('Resharing not allowed. Only the owner can generate a share token.') + expect(e.status, 'error status').to.be.equal(403) + } + + // clean up + await this.test.userbase.deleteUser() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + await this.test.userbase.deleteUser() + }) + + it('Share token invalid', async function () { + await signUp(this.test.userbase) + + try { + await this.test.userbase.openDatabase({ shareToken: 'a', changeHandler: () => { } }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('ShareTokenInvalid') + expect(e.message, 'error message').to.be.equal('Share token invalid.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Share token not allowed - passing share token to shareDatabase', async function () { + const sender = await signUp(this.test.userbase) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // sender gets share token + const { shareToken } = await this.test.userbase.shareDatabase({ databaseName }) + await this.test.userbase.signOut() + + // recipient signs up and tries to reshare share token + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({ shareToken }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('ShareTokenNotAllowed') + expect(e.message, 'error message').to.be.equal('Share token not allowed.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + await this.test.userbase.deleteUser() + }) + + it('Share token not allowed - passing database name and share token to shareDatabase', async function () { + await signUp(this.test.userbase) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + try { + await this.test.userbase.shareDatabase({ databaseName, shareToken: '' }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('ShareTokenNotAllowed') + expect(e.message, 'error message').to.be.equal('Share token not allowed.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Share token not allowed - passing database ID and share token to shareDatabase', async function () { + await signUp(this.test.userbase) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // get database's id + const { databases: [{ databaseId }] } = await this.test.userbase.getDatabases() + + try { + await this.test.userbase.shareDatabase({ databaseId, shareToken: '' }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('ShareTokenNotAllowed') + expect(e.message, 'error message').to.be.equal('Share token not allowed.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Owner opening with share token not allowed', async function () { + const sender = await signUp(this.test.userbase) + await this.test.userbase.openDatabase({ databaseName, changeHandler: () => { } }) + + // sender gets share token, then tries to open with it + const { shareToken } = await this.test.userbase.shareDatabase({ databaseName }) + await this.test.userbase.signOut() + await this.test.userbase.signIn({ username: sender.username, password: sender.password, rememberMe: 'none' }) + + // sender tries to share database with self + try { + await this.test.userbase.openDatabase({ shareToken, changeHandler: () => { } }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('ShareTokenNotAllowedForOwnDatabase') + expect(e.message, 'error message').to.be.equal("Tried to open the user's own database using its shareToken rather than its databaseName. The shareToken should only be used to open databases shared from other users.") + expect(e.status, 'error status').to.be.equal(403) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Database not found - does not exist', async function () { + // sign up sender + await signUp(this.test.userbase) + + // sender tries to share non-existent database + try { + await this.test.userbase.shareDatabase({ databaseName }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('DatabaseNotFound') + expect(e.message, 'error message').to.be.equal('Database not found. Find available databases using getDatabases().') + expect(e.status, 'error status').to.be.equal(404) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Database name missing', async function () { + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({}) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('DatabaseNameMissing') + expect(e.message, 'error message').to.be.equal('Database name missing.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Database name must be string', async function () { + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({ databaseName: 1 }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('DatabaseNameMustBeString') + expect(e.message, 'error message').to.be.equal('Database name must be a string.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Database name cannot be blank', async function () { + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({ databaseName: '' }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('DatabaseNameCannotBeBlank') + expect(e.message, 'error message').to.be.equal('Database name cannot be blank.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Database name too long', async function () { + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({ databaseName: 'a'.repeat(101) }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.equal('DatabaseNameTooLong') + expect(e.message, 'error message').to.equal('Database name cannot be more than 100 characters.') + expect(e.status, 'error status').to.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Database name restricted', async function () { + const verifiedUsersDatabaseName = '__userbase_verified_users' + + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({ databaseName: verifiedUsersDatabaseName }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.equal('DatabaseNameRestricted') + expect(e.message, 'error message').to.equal(`Database name '${verifiedUsersDatabaseName}' is restricted. It is used internally by userbase-js.`) + expect(e.status, 'error status').to.equal(403) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Database id not allowed', async function () { + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({ databaseId: 'abc', databaseName }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('DatabaseIdNotAllowed') + expect(e.message, 'error message').to.be.equal('Database id not allowed. Cannot provide both databaseName and databaseId, can only provide one.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Database id must be string', async function () { + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({ databaseId: 1 }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.equal('DatabaseIdMustBeString') + expect(e.message, 'error message').to.equal('Database id must be a string.') + expect(e.status, 'error status').to.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Database id cannot be blank', async function () { + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({ databaseId: '' }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('DatabaseIdCannotBeBlank') + expect(e.message, 'error message').to.be.equal('Database id cannot be blank.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Database id invalid length', async function () { + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({ databaseId: 'abc' }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('DatabaseIdInvalidLength') + expect(e.message, 'error message').to.be.equal('Database id invalid length. Must be 36 characters.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Read Only must be boolean', async function () { + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({ databaseName, readOnly: 'not boolean' }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('ReadOnlyMustBeBoolean') + expect(e.message, 'error message').to.be.equal('Read only value must be a boolean.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Resharing allowed param not allowed', async function () { + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({ databaseName, resharingAllowed: true }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('ResharingAllowedParamNotAllowed') + expect(e.message, 'error message').to.be.equal('Resharing allowed parameter not allowed when retrieving a share token.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('Require verified param not necessary', async function () { + await signUp(this.test.userbase) + + try { + await this.test.userbase.shareDatabase({ databaseName, requireVerified: true }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('RequireVerifiedParamNotNecessary') + expect(e.message, 'error message').to.be.equal('Require verified parameter not necessary when sharing database without a username.') + expect(e.status, 'error status').to.be.equal(400) + } + + // clean up + await this.test.userbase.deleteUser() + }) + + it('User not signed in', async function () { + try { + await this.test.userbase.shareDatabase({ databaseName }) + throw new Error('Should have failed') + } catch (e) { + expect(e.name, 'error name').to.be.equal('UserNotSignedIn') + expect(e.message, 'error message').to.be.equal('Not signed in.') + expect(e.status, 'error status').to.be.equal(400) + } + }) + + }) + + }) +})