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
This commit is contained in:
Joel Jeremy Marquez
2025-04-08 09:12:11 -07:00
committed by GitHub
parent 6fb7fe1343
commit c77168fa18
13 changed files with 537 additions and 104 deletions

View File

@@ -49,7 +49,7 @@ export function FixEncryptionKeyModal({
const { error } = await send('key-test', {
password,
fileId: cloudFileId,
cloudFileId,
});
if (error) {
setError(getTestKeyError(error));

View File

@@ -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) {

View File

@@ -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<AuthHandlers>();
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<typeof getServer>;
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 {};
}

View File

@@ -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<EncryptionHandlers>();
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<Budget['cloudFileId']>;
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 {};
}

View File

@@ -1,4 +1,4 @@
import * as encryption from './encryption';
import * as encryption from '.';
afterEach(() => encryption.unloadAllKeys());

View File

@@ -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');
});
});

View File

@@ -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() {

View File

@@ -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];

View File

@@ -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<boolean>;
'subscribe-needs-bootstrap': (args: { url }) => Promise<

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [joel-jeremy]
---
Extract encryption related server handlers from main.ts to server/encryption/app.ts