From c77168fa18d693e54c07b506ec7ba3c46894644c Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Tue, 8 Apr 2025 09:12:11 -0700 Subject: [PATCH] Extract encryption related server handlers from main.ts to server/encryption/app.ts (#4662) * Extract encryption related server handlers from main.ts to server/encryption/app.ts * Release notes --- .../modals/FixEncryptionKeyModal.tsx | 2 +- packages/loot-core/src/server/api.ts | 2 +- packages/loot-core/src/server/auth/app.ts | 371 ++++++++++++++++++ .../loot-core/src/server/encryption/app.ts | 132 +++++++ .../{ => encryption}/encryption-internals.ts | 0 .../encryption-internals.web.ts | 0 .../{ => encryption}/encryption.test.ts | 2 +- .../src/server/encryption/index.test.ts | 19 + .../{encryption.ts => encryption/index.ts} | 0 packages/loot-core/src/server/main.ts | 94 +---- packages/loot-core/src/types/handlers.d.ts | 4 +- .../loot-core/src/types/server-handlers.d.ts | 9 - upcoming-release-notes/4662.md | 6 + 13 files changed, 537 insertions(+), 104 deletions(-) create mode 100644 packages/loot-core/src/server/auth/app.ts create mode 100644 packages/loot-core/src/server/encryption/app.ts rename packages/loot-core/src/server/{ => encryption}/encryption-internals.ts (100%) rename packages/loot-core/src/server/{ => encryption}/encryption-internals.web.ts (100%) rename packages/loot-core/src/server/{ => encryption}/encryption.test.ts (91%) create mode 100644 packages/loot-core/src/server/encryption/index.test.ts rename packages/loot-core/src/server/{encryption.ts => encryption/index.ts} (100%) create mode 100644 upcoming-release-notes/4662.md diff --git a/packages/desktop-client/src/components/modals/FixEncryptionKeyModal.tsx b/packages/desktop-client/src/components/modals/FixEncryptionKeyModal.tsx index b3487b93da..0caa710983 100644 --- a/packages/desktop-client/src/components/modals/FixEncryptionKeyModal.tsx +++ b/packages/desktop-client/src/components/modals/FixEncryptionKeyModal.tsx @@ -49,7 +49,7 @@ export function FixEncryptionKeyModal({ const { error } = await send('key-test', { password, - fileId: cloudFileId, + cloudFileId, }); if (error) { setError(getTestKeyError(error)); diff --git a/packages/loot-core/src/server/api.ts b/packages/loot-core/src/server/api.ts index f5304f2f7a..df8daff177 100644 --- a/packages/loot-core/src/server/api.ts +++ b/packages/loot-core/src/server/api.ts @@ -203,7 +203,7 @@ handlers['api/download-budget'] = async function ({ syncId, password }) { } const result = await handlers['key-test']({ - fileId: remoteBudget ? remoteBudget.fileId : localBudget.cloudFileId, + cloudFileId: remoteBudget ? remoteBudget.fileId : localBudget.cloudFileId, password, }); if (result.error) { diff --git a/packages/loot-core/src/server/auth/app.ts b/packages/loot-core/src/server/auth/app.ts new file mode 100644 index 0000000000..608abce8a2 --- /dev/null +++ b/packages/loot-core/src/server/auth/app.ts @@ -0,0 +1,371 @@ +import * as asyncStorage from '../../platform/server/asyncStorage'; +import { OpenIdConfig } from '../../types/models'; +import { createApp } from '../app'; +import * as encryption from '../encryption'; +import { PostError } from '../errors'; +import { get, post } from '../post'; +import { getServer, isValidBaseURL } from '../server-config'; + +export type AuthHandlers = { + 'get-did-bootstrap': typeof didBootstrap; + 'subscribe-needs-bootstrap': typeof needsBootstrap; + 'subscribe-bootstrap': typeof bootstrap; + 'subscribe-get-login-methods': typeof getLoginMethods; + 'subscribe-get-user': typeof getUser; + 'subscribe-change-password': typeof changePassword; + 'subscribe-sign-in': typeof signIn; + 'subscribe-sign-out': typeof signOut; + 'subscribe-set-token': typeof setToken; + 'enable-openid': typeof enableOpenId; + 'get-openid-config': typeof getOpenIdConfig; + 'enable-password': typeof enablePassword; +}; + +export const app = createApp(); +app.method('get-did-bootstrap', didBootstrap); +app.method('subscribe-needs-bootstrap', needsBootstrap); +app.method('subscribe-bootstrap', bootstrap); +app.method('subscribe-get-login-methods', getLoginMethods); +app.method('subscribe-get-user', getUser); +app.method('subscribe-change-password', changePassword); +app.method('subscribe-sign-in', signIn); +app.method('subscribe-sign-out', signOut); +app.method('subscribe-set-token', setToken); +app.method('enable-openid', enableOpenId); +app.method('get-openid-config', getOpenIdConfig); +app.method('enable-password', enablePassword); + +async function didBootstrap() { + return Boolean(await asyncStorage.getItem('did-bootstrap')); +} + +async function needsBootstrap({ url }: { url?: string } = {}) { + if (url && !isValidBaseURL(url)) { + return { error: 'get-server-failure' }; + } + + let serverConfig: ReturnType; + + try { + serverConfig = getServer(url); + if (!serverConfig) { + return { bootstrapped: true, hasServer: false }; + } + } catch (err) { + return { error: 'get-server-failure' }; + } + + let resText: string; + try { + resText = await get(serverConfig.SIGNUP_SERVER + '/needs-bootstrap'); + } catch (err) { + return { error: 'network-failure' }; + } + + let res: { + status: 'ok'; + data: { + bootstrapped: boolean; + loginMethod: 'password' | 'openid' | string; + availableLoginMethods: Array<{ + method: string; + displayName: string; + active: boolean; + }>; + multiuser: boolean; + }; + }; + + try { + res = JSON.parse(resText); + } catch (err) { + return { error: 'parse-failure' }; + } + + return { + bootstrapped: res.data.bootstrapped, + availableLoginMethods: res.data.availableLoginMethods || [ + { method: 'password', active: true, displayName: 'Password' }, + ], + multiuser: res.data.multiuser || false, + hasServer: true, + }; +} + +async function bootstrap(loginConfig: { + password?: string; + openId?: OpenIdConfig; +}) { + try { + const serverConfig = getServer(); + if (!serverConfig) { + throw new Error('No sync server configured.'); + } + await post(serverConfig.SIGNUP_SERVER + '/bootstrap', loginConfig); + } catch (err) { + if (err instanceof PostError) { + return { + error: err.reason || 'network-failure', + }; + } + + throw err; + } + return {}; +} + +async function getLoginMethods() { + let res: { + methods?: Array<{ method: string; displayName: string; active: boolean }>; + }; + try { + const serverConfig = getServer(); + if (!serverConfig) { + throw new Error('No sync server configured.'); + } + res = await fetch(serverConfig.SIGNUP_SERVER + '/login-methods').then(res => + res.json(), + ); + } catch (err) { + if (err instanceof PostError) { + return { + error: err.reason || 'network-failure', + }; + } + + throw err; + } + + if (res.methods) { + return { methods: res.methods }; + } + return { error: 'internal' }; +} + +async function getUser() { + const serverConfig = getServer(); + if (!serverConfig) { + if (!(await asyncStorage.getItem('did-bootstrap'))) { + return null; + } + return { offline: false }; + } + + const userToken = await asyncStorage.getItem('user-token'); + + if (!userToken) { + return null; + } + + try { + const res = await get(serverConfig.SIGNUP_SERVER + '/validate', { + headers: { + 'X-ACTUAL-TOKEN': userToken, + }, + }); + let tokenExpired = false; + const { + status, + reason, + data: { + userName = null, + permission = '', + userId = null, + displayName = null, + loginMethod = null, + } = {}, + } = JSON.parse(res) || {}; + + if (status === 'error') { + if (reason === 'unauthorized') { + return null; + } else if (reason === 'token-expired') { + tokenExpired = true; + } else { + return { offline: true }; + } + } + + return { + offline: false, + userName, + permission, + userId, + displayName, + loginMethod, + tokenExpired, + }; + } catch (e) { + console.log(e); + return { offline: true }; + } +} + +async function changePassword({ password }: { password: string }) { + const userToken = await asyncStorage.getItem('user-token'); + if (!userToken) { + return { error: 'not-logged-in' }; + } + + try { + const serverConfig = getServer(); + if (!serverConfig) { + throw new Error('No sync server configured.'); + } + await post(serverConfig.SIGNUP_SERVER + '/change-password', { + token: userToken, + password, + }); + } catch (err) { + if (err instanceof PostError) { + return { + error: err.reason || 'network-failure', + }; + } + + throw err; + } + + return {}; +} + +async function signIn( + loginInfo: + | { + password: string; + loginMethod?: string; + } + | { + returnUrl: string; + loginMethod?: 'openid'; + }, +) { + if ( + typeof loginInfo.loginMethod !== 'string' || + loginInfo.loginMethod == null + ) { + loginInfo.loginMethod = 'password'; + } + let res: { + token?: string; + redirect_url?: string; + }; + + try { + const serverConfig = getServer(); + if (!serverConfig) { + throw new Error('No sync server configured.'); + } + res = await post(serverConfig.SIGNUP_SERVER + '/login', loginInfo); + } catch (err) { + if (err instanceof PostError) { + return { + error: err.reason || 'network-failure', + }; + } + + throw err; + } + + if (res.redirect_url) { + return { redirectUrl: res.redirect_url }; + } + + if (!res.token) { + throw new Error('login: User token not set'); + } + + await asyncStorage.setItem('user-token', res.token); + return {}; +} + +async function signOut() { + encryption.unloadAllKeys(); + await asyncStorage.multiRemove([ + 'user-token', + 'encrypt-keys', + 'lastBudget', + 'readOnly', + ]); + return 'ok'; +} + +async function setToken({ token }: { token: string }) { + await asyncStorage.setItem('user-token', token); +} + +async function enableOpenId(openIdConfig: { openId: OpenIdConfig }) { + try { + const userToken = await asyncStorage.getItem('user-token'); + + if (!userToken) { + return { error: 'unauthorized' }; + } + + const serverConfig = getServer(); + if (!serverConfig) { + throw new Error('No sync server configured.'); + } + + await post(serverConfig.BASE_SERVER + '/openid/enable', openIdConfig, { + 'X-ACTUAL-TOKEN': userToken, + }); + } catch (err) { + if (err instanceof PostError) { + return { + error: err.reason || 'network-failure', + }; + } + + throw err; + } + return {}; +} + +async function getOpenIdConfig() { + try { + const serverConfig = getServer(); + if (!serverConfig) { + throw new Error('No sync server configured.'); + } + + const res = await get(serverConfig.BASE_SERVER + '/openid/config'); + + if (res) { + const config = JSON.parse(res) as OpenIdConfig; + return { openId: config }; + } + + return null; + } catch (err) { + return { error: 'config-fetch-failed' }; + } +} + +async function enablePassword(passwordConfig: { password: string }) { + try { + const userToken = await asyncStorage.getItem('user-token'); + + if (!userToken) { + return { error: 'unauthorized' }; + } + + const serverConfig = getServer(); + if (!serverConfig) { + throw new Error('No sync server configured.'); + } + + await post(serverConfig.BASE_SERVER + '/openid/disable', passwordConfig, { + 'X-ACTUAL-TOKEN': userToken, + }); + } catch (err) { + if (err instanceof PostError) { + return { + error: err.reason || 'network-failure', + }; + } + + throw err; + } + return {}; +} diff --git a/packages/loot-core/src/server/encryption/app.ts b/packages/loot-core/src/server/encryption/app.ts new file mode 100644 index 0000000000..f6102c2510 --- /dev/null +++ b/packages/loot-core/src/server/encryption/app.ts @@ -0,0 +1,132 @@ +import { v4 as uuidv4 } from 'uuid'; + +import * as asyncStorage from '../../platform/server/asyncStorage'; +import { Budget } from '../../types/budget'; +import { createApp } from '../app'; +import { post } from '../post'; +import * as prefs from '../prefs'; +import { getServer } from '../server-config'; +import { makeTestMessage, resetSync } from '../sync'; + +import * as encryption from '.'; + +export type EncryptionHandlers = { + 'key-make': typeof keyMake; + 'key-test': typeof keyTest; +}; + +export const app = createApp(); +app.method('key-make', keyMake); +app.method('key-test', keyTest); + +// A user can only enable/change their key with the file loaded. This +// will change in the future: during onboarding the user should be +// able to enable encryption. (Imagine if they are importing data from +// another source, they should be able to encrypt first) +async function keyMake({ password }: { password: string }) { + if (!prefs.getPrefs()) { + throw new Error('key-make must be called with file loaded'); + } + + const salt = encryption.randomBytes(32).toString('base64'); + const id = uuidv4(); + const key = await encryption.createKey({ id, password, salt }); + + // Load the key + await encryption.loadKey(key); + + // Make some test data to use if the key is valid or not + const testContent = await makeTestMessage(key.getId()); + + // Changing your key necessitates a sync reset as well. This will + // clear all existing encrypted data from the server so you won't + // have a mix of data encrypted with different keys. + return await resetSync({ + key, + salt, + testContent: JSON.stringify({ + ...testContent, + value: testContent.value.toString('base64'), + }), + }); +} + +// This can be called both while a file is already loaded or not. This +// will see if a key is valid and if so save it off. +async function keyTest({ + cloudFileId, + password, +}: { + cloudFileId?: Budget['cloudFileId']; + password: string; +}) { + const userToken = await asyncStorage.getItem('user-token'); + + if (cloudFileId == null) { + cloudFileId = prefs.getPrefs().cloudFileId; + } + + let validCloudFileId: NonNullable; + let res: { + id: string; + salt: string; + test: string | null; + }; + try { + const serverConfig = getServer(); + if (!serverConfig) { + throw new Error('No sync server configured.'); + } + res = await post(serverConfig.SYNC_SERVER + '/user-get-key', { + token: userToken, + fileId: cloudFileId, + }); + validCloudFileId = cloudFileId!; + } catch (e) { + console.log(e); + return { error: { reason: 'network' } }; + } + + const { id, salt, test: originalTest } = res; + + if (!originalTest) { + return { error: { reason: 'old-key-style' } }; + } + + const test: { + value: string; + meta: { + keyId: string; + algorithm: string; + iv: string; + authTag: string; + }; + } = JSON.parse(originalTest); + + const key = await encryption.createKey({ id, password, salt }); + encryption.loadKey(key); + + try { + await encryption.decrypt(Buffer.from(test.value, 'base64'), test.meta); + } catch (e) { + console.log(e); + + // Unload the key, it's invalid + encryption.unloadKey(key); + return { error: { reason: 'decrypt-failure' } }; + } + + // Persist key in async storage + const keys = JSON.parse((await asyncStorage.getItem(`encrypt-keys`)) || '{}'); + keys[validCloudFileId] = key.serialize(); + await asyncStorage.setItem('encrypt-keys', JSON.stringify(keys)); + + // Save the key id in prefs if the are loaded. If they aren't, we + // are testing a key to download a file and when the file is + // actually downloaded it will update the prefs with the latest key id + if (prefs.getPrefs()) { + await prefs.savePrefs({ encryptKeyId: key.getId() }); + } + + return {}; +} diff --git a/packages/loot-core/src/server/encryption-internals.ts b/packages/loot-core/src/server/encryption/encryption-internals.ts similarity index 100% rename from packages/loot-core/src/server/encryption-internals.ts rename to packages/loot-core/src/server/encryption/encryption-internals.ts diff --git a/packages/loot-core/src/server/encryption-internals.web.ts b/packages/loot-core/src/server/encryption/encryption-internals.web.ts similarity index 100% rename from packages/loot-core/src/server/encryption-internals.web.ts rename to packages/loot-core/src/server/encryption/encryption-internals.web.ts diff --git a/packages/loot-core/src/server/encryption.test.ts b/packages/loot-core/src/server/encryption/encryption.test.ts similarity index 91% rename from packages/loot-core/src/server/encryption.test.ts rename to packages/loot-core/src/server/encryption/encryption.test.ts index 14d5d265d6..23317693ca 100644 --- a/packages/loot-core/src/server/encryption.test.ts +++ b/packages/loot-core/src/server/encryption/encryption.test.ts @@ -1,4 +1,4 @@ -import * as encryption from './encryption'; +import * as encryption from '.'; afterEach(() => encryption.unloadAllKeys()); diff --git a/packages/loot-core/src/server/encryption/index.test.ts b/packages/loot-core/src/server/encryption/index.test.ts new file mode 100644 index 0000000000..23317693ca --- /dev/null +++ b/packages/loot-core/src/server/encryption/index.test.ts @@ -0,0 +1,19 @@ +import * as encryption from '.'; + +afterEach(() => encryption.unloadAllKeys()); + +describe('Encryption', () => { + test('should encrypt and decrypt', async () => { + const key = await encryption.createKey({ + id: 'foo', + password: 'mypassword', + salt: 'salt', + }); + await encryption.loadKey(key); + + const data = await encryption.encrypt('hello', 'foo'); + + const output = await encryption.decrypt(data.value, data.meta); + expect(output.toString()).toBe('hello'); + }); +}); diff --git a/packages/loot-core/src/server/encryption.ts b/packages/loot-core/src/server/encryption/index.ts similarity index 100% rename from packages/loot-core/src/server/encryption.ts rename to packages/loot-core/src/server/encryption/index.ts diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts index d3abd3f483..9571668547 100644 --- a/packages/loot-core/src/server/main.ts +++ b/packages/loot-core/src/server/main.ts @@ -2,7 +2,6 @@ import './polyfills'; import * as injectAPI from '@actual-app/api/injected'; -import { v4 as uuidv4 } from 'uuid'; import * as asyncStorage from '../platform/server/asyncStorage'; import * as connection from '../platform/server/connection'; @@ -21,6 +20,7 @@ import { app as budgetFilesApp } from './budgetfiles/app'; import { app as dashboardApp } from './dashboard/app'; import * as db from './db'; import * as encryption from './encryption'; +import { app as encryptionApp } from './encryption/app'; import { app as filtersApp } from './filters/app'; import { app } from './main-app'; import { mutator, runHandler } from './mutators'; @@ -35,7 +35,7 @@ import { app as rulesApp } from './rules/app'; import { app as schedulesApp } from './schedules/app'; import { getServer, isValidBaseURL, setServer } from './server-config'; import { app as spreadsheetApp } from './spreadsheet/app'; -import { fullSync, setSyncingMode, makeTestMessage, resetSync } from './sync'; +import { fullSync, setSyncingMode } from './sync'; import { app as syncApp } from './sync/app'; import { app as toolsApp } from './tools/app'; import { app as transactionsApp } from './transactions/app'; @@ -71,95 +71,6 @@ handlers['query'] = async function (query) { return aqlQuery(query); }; -// A user can only enable/change their key with the file loaded. This -// will change in the future: during onboarding the user should be -// able to enable encryption. (Imagine if they are importing data from -// another source, they should be able to encrypt first) -handlers['key-make'] = async function ({ password }) { - if (!prefs.getPrefs()) { - throw new Error('user-set-key must be called with file loaded'); - } - - const salt = encryption.randomBytes(32).toString('base64'); - const id = uuidv4(); - const key = await encryption.createKey({ id, password, salt }); - - // Load the key - await encryption.loadKey(key); - - // Make some test data to use if the key is valid or not - const testContent = await makeTestMessage(key.getId()); - - // Changing your key necessitates a sync reset as well. This will - // clear all existing encrypted data from the server so you won't - // have a mix of data encrypted with different keys. - return await resetSync({ - key, - salt, - testContent: JSON.stringify({ - ...testContent, - value: testContent.value.toString('base64'), - }), - }); -}; - -// This can be called both while a file is already loaded or not. This -// will see if a key is valid and if so save it off. -handlers['key-test'] = async function ({ fileId, password }) { - const userToken = await asyncStorage.getItem('user-token'); - - if (fileId == null) { - fileId = prefs.getPrefs().cloudFileId; - } - - let res; - try { - res = await post(getServer().SYNC_SERVER + '/user-get-key', { - token: userToken, - fileId, - }); - } catch (e) { - console.log(e); - return { error: { reason: 'network' } }; - } - - const { id, salt, test: originalTest } = res; - - let test = originalTest; - if (test == null) { - return { error: { reason: 'old-key-style' } }; - } - - test = JSON.parse(test); - - const key = await encryption.createKey({ id, password, salt }); - encryption.loadKey(key); - - try { - await encryption.decrypt(Buffer.from(test.value, 'base64'), test.meta); - } catch (e) { - console.log(e); - - // Unload the key, it's invalid - encryption.unloadKey(key); - return { error: { reason: 'decrypt-failure' } }; - } - - // Persist key in async storage - const keys = JSON.parse((await asyncStorage.getItem(`encrypt-keys`)) || '{}'); - keys[fileId] = key.serialize(); - await asyncStorage.setItem('encrypt-keys', JSON.stringify(keys)); - - // Save the key id in prefs if the are loaded. If they aren't, we - // are testing a key to download a file and when the file is - // actually downloaded it will update the prefs with the latest key id - if (prefs.getPrefs()) { - await prefs.savePrefs({ encryptKeyId: key.getId() }); - } - - return {}; -}; - handlers['get-did-bootstrap'] = async function () { return Boolean(await asyncStorage.getItem('did-bootstrap')); }; @@ -529,6 +440,7 @@ app.combine( spreadsheetApp, syncApp, budgetFilesApp, + encryptionApp, ); export function getDefaultDocumentDir() { diff --git a/packages/loot-core/src/types/handlers.d.ts b/packages/loot-core/src/types/handlers.d.ts index a2c3d93c10..49054e7709 100644 --- a/packages/loot-core/src/types/handlers.d.ts +++ b/packages/loot-core/src/types/handlers.d.ts @@ -3,6 +3,7 @@ import type { AdminHandlers } from '../server/admin/app'; import type { BudgetHandlers } from '../server/budget/app'; import type { BudgetFileHandlers } from '../server/budgetfiles/app'; import type { DashboardHandlers } from '../server/dashboard/app'; +import type { EncryptionHandlers } from '../server/encryption/app'; import type { FiltersHandlers } from '../server/filters/app'; import type { NotesHandlers } from '../server/notes/app'; import type { PayeesHandlers } from '../server/payees/app'; @@ -36,6 +37,7 @@ export interface Handlers PayeesHandlers, SpreadsheetHandlers, SyncHandlers, - BudgetFileHandlers {} + BudgetFileHandlers, + EncryptionHandlers {} export type HandlerFunctions = Handlers[keyof Handlers]; diff --git a/packages/loot-core/src/types/server-handlers.d.ts b/packages/loot-core/src/types/server-handlers.d.ts index 45aeb3459f..0b2e1ea96f 100644 --- a/packages/loot-core/src/types/server-handlers.d.ts +++ b/packages/loot-core/src/types/server-handlers.d.ts @@ -15,15 +15,6 @@ export interface ServerHandlers { // eslint-disable-next-line @typescript-eslint/no-explicit-any query: (query: QueryState) => Promise<{ data: any; dependencies: string[] }>; - 'key-make': (arg: { - password; - }) => Promise<{ error?: { reason: string; meta?: unknown } }>; - - 'key-test': (arg: { - fileId; - password; - }) => Promise<{ error?: { reason: string } }>; - 'get-did-bootstrap': () => Promise; 'subscribe-needs-bootstrap': (args: { url }) => Promise< diff --git a/upcoming-release-notes/4662.md b/upcoming-release-notes/4662.md new file mode 100644 index 0000000000..f2c5ab18d9 --- /dev/null +++ b/upcoming-release-notes/4662.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [joel-jeremy] +--- + +Extract encryption related server handlers from main.ts to server/encryption/app.ts