diff --git a/app/sdk/__mocks__/account.ts b/app/sdk/__mocks__/account.ts index 99e45ef2..87d0a994 100644 --- a/app/sdk/__mocks__/account.ts +++ b/app/sdk/__mocks__/account.ts @@ -49,13 +49,16 @@ export class MockAccountModule implements Account { public async confirmMFA(code: string): Promise { } - public async setAccountSeal(password: string): Promise { + public async setSeal(password: string): Promise { } - public async clearAccountSeal(): Promise { + public async clearSeal(): Promise { } - public async unlockAccountSeal(password: string): Promise { + public async unlockSeal(password: string): Promise { + } + + public async forgetSeal(): Promise { } public async setLogin(username: string, password: string): Promise { diff --git a/app/sdk/__tests__/account.tests.ts b/app/sdk/__tests__/account.tests.ts new file mode 100644 index 00000000..c785345f --- /dev/null +++ b/app/sdk/__tests__/account.tests.ts @@ -0,0 +1,104 @@ +import { AccountModule } from '../src/account'; +import { NoStore } from '../src/store'; +import { Crypto } from '../src/crypto'; +import { ConsoleLogging } from '../src/logging'; +import { defaultAccountEntity } from '../src/entities'; +import { AccountStatus } from '../src/types'; +import { waitFor } from '../__mocks__/waitFor'; +import axios from 'redaxios'; + +const testStatus = JSON.parse(JSON.stringify(defaultAccountEntity)); + +jest.mock('redaxios', () => { + return { + get: jest.fn().mockImplementation(() => { + testStatus.storageUsed = 2; + return Promise.resolve({ status: 200, data: testStatus }); + }), + put: jest.fn().mockImplementation((url, body) => { + if (url == 'http://test_url/account/notification?agent=test_token') { + testStatus.pushEnabled = body; + } + if (url == 'http://test_url/account/searchable?agent=test_token') { + testStatus.searchable = body; + } + if (url == 'http://test_url/account/seal?agent=test_token') { + testStatus.seal = body; + } + return Promise.resolve({ status: 200 }); + }), + } +}) + +class TestCrypto implements Crypto { + + public pbkdfSalt() { + return { saltHex: 'SALT_HEX' } + } + + public pbkdfKey(saltHex: string, password: string) { + return { aesKeyHex: 'AES_KEY_HEX' } + } + + public aesKey() { + return { aesKeyHex: 'AES_KEY_HEX' }; + } + + public aesIv() { + return { ivHex: 'IV_HEX' }; + } + + public aesEncrypt(data: string, ivHex: string, aesKeyHex: string) { + return { encryptedDataB64: 'ENCRYPTED_DATA_B64' }; + } + + public aesDecrypt(encryptedDataB64: string, ivHex: string, aesKeyHex: string) { + return { data: 'DATA' } + } + + public rsaKey() { + return { publicKeyB64: 'PUBLIC_KEY_B64', privateKeyB64: 'PRIVATE_KEY_B64' }; + } + + public rsaEncrypt(data: string, publicKeyB64: string) { + return { encryptedDataB64: 'ENCRYPTED_DATA_B64' } + } + + public rsaDecrypt(encryptedDataB64: string, privateKeyB64: string) { + return { data: 'DATA' } + } +} + +class TestStore extends NoStore { + public async getProfileRevision(): Promise { + return 4; + } +} + +test('allocates session correctly', async () => { + let status: AccountStatus | null = null; + const log = new ConsoleLogging(); + const store = new TestStore(); + const crypto = new TestCrypto(); + const account = new AccountModule(log, store, crypto, 'test_guid', 'test_token', 'test_url', false); + account.addStatusListener((ev: AccountStatus) => { status = ev }); + account.setRevision(5); + await waitFor(() => (status?.storageUsed == 2)); + account.enableRegistry(); + account.setRevision(6); + await waitFor(() => Boolean(status?.searchable)); + account.disableRegistry(); + account.setRevision(7); + await waitFor(() => !Boolean(status?.searchable)); + + account.enableNotifications(); + account.setRevision(8); + await waitFor(() => Boolean(status?.pushEnabled)); + account.disableNotifications(); + account.setRevision(9); + await waitFor(() => !Boolean(status?.pushEnabled)); + + account.setSeal('password'); + account.setRevision(10); + await waitFor(() => Boolean(status?.sealSet)); +}); diff --git a/app/sdk/src/account.ts b/app/sdk/src/account.ts index a98094fc..f404ec85 100644 --- a/app/sdk/src/account.ts +++ b/app/sdk/src/account.ts @@ -1,7 +1,9 @@ import { EventEmitter } from 'eventemitter3'; -import type { Account, Logging } from './api'; +import type { Account } from './api'; import type { AccountStatus } from './types'; import { Store } from './store'; +import { Crypto } from './crypto'; +import { Logging } from './logging'; import { defaultAccountEntity, AccountEntity } from './entities'; import { getAccountStatus } from './net/getAccountStatus'; import { addAccountMFAuth } from './net/addAccountMFAuth'; @@ -12,7 +14,6 @@ import { setAccountNotifications } from './net/setAccountNotifications'; import { setAccountSearchable } from './net/setAccountSearchable'; import { setAccountSeal } from './net/setAccountSeal'; import { clearAccountSeal } from './net/clearAccountSeal'; -import { Crypto } from './crypto'; const CLOSE_POLL_MS = 100; const RETRY_POLL_MS = 2000; @@ -25,6 +26,7 @@ export class AccountModule implements Account { private node: string; private secure: boolean; private log: Logging; + private store: Store; private crypto: Crypto | null; private syncing: boolean; private closing: boolean; @@ -35,11 +37,13 @@ export class AccountModule implements Account { constructor(log: Logging, store: Store, crypto: Crypto | null, guid: string, token: string, node: string, secure: boolean) { this.log = log; + this.store = store; + this.crypto = crypto; this.emitter = new EventEmitter(); this.guid = guid; this.token = token; this.node = node; - this.seal = null; + this.sealKey = null; this.secure = secure; this.revision = 0; this.entity = defaultAccountEntity; @@ -52,7 +56,7 @@ export class AccountModule implements Account { private async init() { this.revision = await this.store.getAccountRevision(this.guid); this.entity = await this.store.getAccountData(this.guid); - this.seal = await this.store.getSeal(this.guid); + this.sealKey = await this.store.getSeal(this.guid); this.syncing = false; await this.sync(); } @@ -69,7 +73,7 @@ export class AccountModule implements Account { try { const { guid, node, secure, token } = this; const status = await getAccountStatus(node, secure, token); - await this.store.setAccountStatus(guid, status); + await this.store.setAccountData(guid, status); await this.store.setAccountRevision(guid, nextRev); this.entity = status; this.emitter.emit('status', this.getStatus()); @@ -90,9 +94,11 @@ export class AccountModule implements Account { } public getStatus() { - const { storageUsed, storageAvailable, forwardingAddress, searchable, allowUnseaed, pushEnabled, sealable, seal, enableIce, multiFactorAuth, webPushKey } = this.entity; - const sealSet = this.seal && seal && this.seal.publicKey == seal.publicKey && this.seal.privateKey - return { storageUsed, storageAvailable, forwardingAddress, searchable, allowUnsealed, pushEnabled, sealable, sealSet, enableIce, multiFactorAuth, webPushKey }; + const { storageUsed, storageAvailable, forwardingAddress, searchable, allowUnsealed, pushEnabled, sealable, seal, enableIce, multiFactorAuth, webPushKey } = this.entity; + const { passwordSalt, privateKeyIv, privateKeyEncrypted, publicKey } = seal || {}; + const sealSet = Boolean(passwordSalt && privateKeyIv && privateKeyEncrypted && publicKey); + const sealUnlocked = Boolean(sealSet && this.sealKey?.privateKey && this.sealKey?.publicKey == publicKey) + return { storageUsed, storageAvailable, forwardingAddress, searchable, allowUnsealed, pushEnabled, sealable, sealSet, sealUnlocked, enableIce, multiFactorAuth, webPushKey }; } public addStatusListener(ev: (status: AccountStatus) => void): void { @@ -104,7 +110,7 @@ export class AccountModule implements Account { this.emitter.off('status', ev); } - public async close(): void { + public async close(): Promise { this.closing = true; while(this.syncing) { await new Promise(r => setTimeout(r, CLOSE_POLL_MS)); @@ -152,7 +158,7 @@ export class AccountModule implements Account { await setAccountMFAuth(node, secure, token, code); } - public async setAccountSeal(password: string): Promise { + public async setSeal(password: string): Promise { const { crypto, guid, node, secure, token } = this; if (!crypto) { throw new Error('crypto not enabled'); @@ -168,19 +174,20 @@ export class AccountModule implements Account { const seal = { publicKey: publicKeyB64, privateKey: privateKeyB64 }; this.store.setSeal(guid, seal); - this.seal = seal; + this.sealKey = seal; this.emitter.emit('status', this.getStatus()); } - public async clearAccountSeal(): Promise { + public async clearSeal(): Promise { const { guid, node, secure, token } = this; - await this.store.clearAccountSeal(guid, node, secure, token); - this.seal = null; + await clearAccountSeal(node, secure, token); + await this.store.clearSeal(guid); + this.sealKey = null; this.emitter.emit('status', this.getStatus()); } - public async unlockAccountSeal(password: string): Promise { + public async unlockSeal(password: string): Promise { const { guid, entity, crypto } = this; const { passwordSalt, privateKeyIv, privateKeyEncrypted, publicKey } = entity.seal; if (!passwordSalt || !privateKeyIv || !privateKeyEncrypted || !publicKey) { @@ -194,11 +201,18 @@ export class AccountModule implements Account { const seal = { publicKey: publicKey, privateKey: data }; this.store.setSeal(guid, seal); - this.seal = seal; + this.sealKey = seal; this.emitter.emit('status', this.getStatus()); } + public async forgetSeal(): Promise { + const { guid } = this; + await this.store.clearSeal(guid); + this.sealKey = null; + this.emitter.emit('status', this.getStatus()); + } + public async setLogin(username: string, password: string): Promise { const { node, secure, token } = this; await setAccountLogin(node, secure, token, username, password); diff --git a/app/sdk/src/api.ts b/app/sdk/src/api.ts index 85317c91..85e4dcea 100644 --- a/app/sdk/src/api.ts +++ b/app/sdk/src/api.ts @@ -91,9 +91,10 @@ export interface Account { enableMFA(): Promise<{ secretImage: string, secretText: string }>; disableMFA(): Promise; confirmMFA(code: string): Promise; - setAccountSeal(password: string): Promise; - clearAccountSeal(): Promise; - unlockAccountSeal(password: string): Promise; + setSeal(password: string): Promise; + clearSeal(): Promise; + unlockSeal(password: string): Promise; + forgetSeal(): Promise; addStatusListener(ev: (status: AccountStatus) => void): void; removeStatusListener(ev: (status: AccountStatus) => void): void; diff --git a/app/sdk/src/net/addAccountMFAuth.ts b/app/sdk/src/net/addAccountMFAuth.ts index f6850d37..7cddd48e 100644 --- a/app/sdk/src/net/addAccountMFAuth.ts +++ b/app/sdk/src/net/addAccountMFAuth.ts @@ -1,10 +1,11 @@ import axios from 'redaxios'; -export async function addAccountMFAuth(node: string, secure: boolean, token: string): { text: string, image: string } { +export async function addAccountMFAuth(node: string, secure: boolean, token: string): Promise<{ text: string, image: string }> { const endpoint = `http${secure ? 's' : ''}://${node}/account/mfauth=${token}`; const response = await axios.post(endpoint); if (response.status >= 400 && response.status < 600) { throw new Error('setAccountMFAuth failed'); } + return response.data; } diff --git a/app/sdk/src/net/clearAccountSeal.ts b/app/sdk/src/net/clearAccountSeal.ts index c279c635..3b8fdf2f 100644 --- a/app/sdk/src/net/clearAccountSeal.ts +++ b/app/sdk/src/net/clearAccountSeal.ts @@ -1,6 +1,6 @@ import axios from 'redaxios'; -export async function setAccountSeal(node: string, secure: boolean, token: string) { +export async function clearAccountSeal(node: string, secure: boolean, token: string) { const endpoint = `http${secure ? 's' : ''}://${node}/account/seal?agent=${token}`; const response = await axios.delete(endpoint); if (response.status >= 400 && response.status < 600) { diff --git a/app/sdk/src/types.ts b/app/sdk/src/types.ts index 15f9b447..bb0c1a93 100644 --- a/app/sdk/src/types.ts +++ b/app/sdk/src/types.ts @@ -145,6 +145,7 @@ export type AccountStatus = { pushEnabled: boolean, sealable: boolean, sealSet: boolean, + sealUnlocked: boolean, enableIce: boolean, multiFactorAuth: boolean, webPushKey: string,