Skip to content

Commit 613599e

Browse files
committed
feat: encrypt token on disk using safe storage api
Signed-off-by: Adam Setch <[email protected]>
1 parent b310a40 commit 613599e

File tree

7 files changed

+91
-7
lines changed

7 files changed

+91
-7
lines changed

src/main/main.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { app, globalShortcut, ipcMain as ipc } from 'electron';
1+
import { app, globalShortcut, ipcMain as ipc, safeStorage } from 'electron';
22
import log from 'electron-log';
33
import { menubar } from 'menubar';
44

@@ -169,6 +169,15 @@ app.whenReady().then(async () => {
169169
});
170170
});
171171

172+
// Safe Storage
173+
ipc.handle(namespacedEvent('safe-storage-encrypt'), (_, settings) => {
174+
return safeStorage.encryptString(settings).toString('base64');
175+
});
176+
177+
ipc.handle(namespacedEvent('safe-storage-decrypt'), (_, settings) => {
178+
return safeStorage.decryptString(Buffer.from(settings, 'base64'));
179+
});
180+
172181
// Handle gitify:// custom protocol URL events for OAuth 2.0 callback
173182
app.on('open-url', (event, url) => {
174183
event.preventDefault();

src/renderer/__mocks__/electron.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ module.exports = {
4343
return Promise.resolve('darwin');
4444
case namespacedEvent('version'):
4545
return Promise.resolve('0.0.1');
46+
case namespacedEvent('safe-storage-encrypt'):
47+
return Promise.resolve('encrypted');
48+
case namespacedEvent('safe-storage-decrypt'):
49+
return Promise.resolve('decrypted');
4650
default:
4751
return Promise.reject(new Error(`Unknown channel: ${channel}`));
4852
}

src/renderer/context/App.tsx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
type Status,
2929
type SystemSettingsState,
3030
Theme,
31+
type Token,
3132
} from '../types';
3233
import type { Notification } from '../typesGitHub';
3334
import { headNotifications } from '../utils/api/client';
@@ -44,6 +45,8 @@ import {
4445
removeAccount,
4546
} from '../utils/auth/utils';
4647
import {
48+
decryptValue,
49+
encryptValue,
4750
setAlternateIdleIcon,
4851
setAutoLaunch,
4952
setKeyboardShortcut,
@@ -281,20 +284,45 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
281284
if (existing.settings) {
282285
setKeyboardShortcut(existing.settings.keyboardShortcut);
283286
setAlternateIdleIcon(existing.settings.useAlternateIdleIcon);
284-
setSettings({ ...defaultSettings, ...existing.settings });
287+
setSettings({
288+
...defaultSettings,
289+
...Object.fromEntries(
290+
Object.entries(existing.settings).filter(
291+
([key]) => key in defaultSettings,
292+
),
293+
),
294+
});
285295
webFrame.setZoomLevel(
286296
zoomPercentageToLevel(existing.settings.zoomPercentage),
287297
);
288298
}
289299

290300
if (existing.auth) {
291-
setAuth({ ...defaultAuth, ...existing.auth });
301+
setAuth({
302+
...defaultAuth,
303+
...Object.fromEntries(
304+
Object.entries(existing.auth).filter(([key]) => key in defaultAuth),
305+
),
306+
});
292307

293308
// Refresh account data on app start
294309
for (const account of existing.auth.accounts) {
310+
/**
311+
* Check if each account has an encrypted token.
312+
* If not encrypt it and save it.
313+
*/
314+
try {
315+
await decryptValue(account.token);
316+
} catch (err) {
317+
const encryptedToken = await encryptValue(account.token);
318+
account.token = encryptedToken as Token;
319+
}
320+
295321
await refreshAccount(account);
296322
}
297323
}
324+
325+
// saveState({ auth: existing.auth, settings });
298326
}, []);
299327

300328
const fetchNotificationsWithAccounts = useCallback(

src/renderer/utils/api/request.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import axios, {
44
type Method,
55
} from 'axios';
66

7-
import { logError } from '../../../shared/logger';
7+
import { logError, logWarn } from '../../../shared/logger';
88
import type { Link, Token } from '../../types';
9+
import { decryptValue } from '../comms';
910
import { getNextURLFromLinkHeader } from './utils';
1011

1112
/**
@@ -44,8 +45,15 @@ export async function apiRequestAuth(
4445
data = {},
4546
fetchAllRecords = false,
4647
): AxiosPromise | null {
48+
let apiToken = token;
49+
try {
50+
apiToken = (await decryptValue(token)) as Token;
51+
} catch (err) {
52+
logWarn('apiRequestAuth', 'Token is not yet encrypted');
53+
}
54+
4755
axios.defaults.headers.common.Accept = 'application/json';
48-
axios.defaults.headers.common.Authorization = `token ${token}`;
56+
axios.defaults.headers.common.Authorization = `token ${apiToken}`;
4957
axios.defaults.headers.common['Content-Type'] = 'application/json';
5058
axios.defaults.headers.common['Cache-Control'] = shouldRequestWithNoCache(url)
5159
? 'no-cache'

src/renderer/utils/auth/utils.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import type {
1818
import type { UserDetails } from '../../typesGitHub';
1919
import { getAuthenticatedUser } from '../api/client';
2020
import { apiRequest } from '../api/request';
21-
import { openExternalLink } from '../comms';
21+
import { encryptValue, openExternalLink } from '../comms';
2222
import { Constants } from '../constants';
2323
import { getPlatformFromHostname } from '../helpers';
2424
import type { AuthMethod, AuthResponse, AuthTokenResponse } from './types';
@@ -109,12 +109,13 @@ export async function addAccount(
109109
hostname: Hostname,
110110
): Promise<AuthState> {
111111
const accountList = auth.accounts;
112+
const encryptedToken = await encryptValue(token);
112113

113114
let newAccount = {
114115
hostname: hostname,
115116
method: method,
116117
platform: getPlatformFromHostname(hostname),
117-
token: token,
118+
token: encryptedToken,
118119
} as Account;
119120

120121
newAccount = await refreshAccount(newAccount);

src/renderer/utils/comms.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { namespacedEvent } from '../../shared/events';
44
import { mockSettings } from '../__mocks__/state-mocks';
55
import type { Link } from '../types';
66
import {
7+
decryptValue,
8+
encryptValue,
79
getAppVersion,
810
hideWindow,
911
openExternalLink,
@@ -68,6 +70,24 @@ describe('renderer/utils/comms.ts', () => {
6870
expect(ipcRenderer.invoke).toHaveBeenCalledWith(namespacedEvent('version'));
6971
});
7072

73+
it('should encrypt a value', async () => {
74+
await encryptValue('value');
75+
expect(ipcRenderer.invoke).toHaveBeenCalledTimes(1);
76+
expect(ipcRenderer.invoke).toHaveBeenCalledWith(
77+
namespacedEvent('safe-storage-encrypt'),
78+
'value',
79+
);
80+
});
81+
82+
it('should decrypt a value', async () => {
83+
await decryptValue('value');
84+
expect(ipcRenderer.invoke).toHaveBeenCalledTimes(1);
85+
expect(ipcRenderer.invoke).toHaveBeenCalledWith(
86+
namespacedEvent('safe-storage-decrypt'),
87+
'value',
88+
);
89+
});
90+
7191
it('should quit the app', () => {
7292
quitApp();
7393
expect(ipcRenderer.send).toHaveBeenCalledTimes(1);

src/renderer/utils/comms.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,20 @@ export async function getAppVersion(): Promise<string> {
2424
return await ipcRenderer.invoke(namespacedEvent('version'));
2525
}
2626

27+
export async function encryptValue(value: string): Promise<string> {
28+
return await ipcRenderer.invoke(
29+
namespacedEvent('safe-storage-encrypt'),
30+
value,
31+
);
32+
}
33+
34+
export async function decryptValue(value: string): Promise<string> {
35+
return await ipcRenderer.invoke(
36+
namespacedEvent('safe-storage-decrypt'),
37+
value,
38+
);
39+
}
40+
2741
export function quitApp(): void {
2842
ipcRenderer.send(namespacedEvent('quit'));
2943
}

0 commit comments

Comments
 (0)