Skip to content

Commit d035d87

Browse files
feat: encrypting JWT tokens (#3290)
* jwtToken is now encrypted in local db. Signed-off-by: Eric Le Ponner <eric.leponner@icloud.com> * Updated unit tests for organizationCredentials.ts. Signed-off-by: Eric Le Ponner <eric.leponner@icloud.com> * jwtToken is now encrypted in local db (take #2) Signed-off-by: Eric Le Ponner <eric.leponner@icloud.com> * userStoreHelpers.getLocalKeyPairs() now passes decryptPassword. Signed-off-by: Eric Le Ponner <eric.leponner@icloud.com> * Fixed some CoPilot issues Signed-off-by: Eric Le Ponner <eric.leponner@icloud.com> * decryptMigrateJwtToken() now uses isClearTextToken(). Signed-off-by: Eric Le Ponner <eric.leponner@icloud.com> * Adjusted unit tests to new isClearTextToken(). Signed-off-by: Eric Le Ponner <eric.leponner@icloud.com> * Added unit test for isClearTextToken(). Signed-off-by: Eric Le Ponner <eric.leponner@icloud.com> * Added missing decryptPassword in calls to getKeyPairs(). Signed-off-by: Eric Le Ponner <eric.leponner@icloud.com> * reconnectOrganization() now passes encryptPassword when calling updateOrganizationCredentials(). Signed-off-by: Eric Le Ponner <eric.leponner@icloud.com> * Fixed tests that were not actually exercising the branches they claimed to cover, causing project coverage to drop. Signed-off-by: John Bair <john.bair@swirldslabs.com> --------- Signed-off-by: Eric Le Ponner <eric.leponner@icloud.com> Signed-off-by: John Bair <john.bair@swirldslabs.com> Co-authored-by: John Bair <117694970+jbair06@users.noreply.github.com> Co-authored-by: John Bair <john.bair@swirldslabs.com>
1 parent cfe77a2 commit d035d87

20 files changed

Lines changed: 472 additions & 164 deletions

File tree

front-end/src/main/modules/ipcHandlers/localUser/keyPairs.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ export default () => {
3333
// Clear keys file
3434
ipcMain.handle(
3535
createChannelName('clear'),
36-
async (_e, userId: string, organizationId?: string) => {
36+
async (_e, userId: string, decryptPassword: string | null, organizationId?: string) => {
3737
try {
38-
await deleteSecretHashes(userId, organizationId);
38+
await deleteSecretHashes(userId, decryptPassword, organizationId);
3939
return true;
4040
} catch {
4141
return false;

front-end/src/main/services/localUser/keyPairs.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const logger = createLogger('main.localUser.keyPairs');
1515
//Get stored key pairs
1616
export const getKeyPairs = async (
1717
user_id: string,
18+
decryptPassword: string | null,
1819
organization_id?: string | null,
1920
): Promise<KeyPair[]> => {
2021
const prisma = getPrismaClient();
@@ -23,7 +24,7 @@ export const getKeyPairs = async (
2324
user_id,
2425
};
2526

26-
await extendWhere(where, organization_id);
27+
await extendWhere(where, decryptPassword, organization_id);
2728

2829
return prisma.keyPair.findMany({
2930
where,
@@ -71,7 +72,7 @@ export const changeDecryptionPassword = async (
7172
) => {
7273
const prisma = getPrismaClient();
7374

74-
const keyPairs = await getKeyPairs(userId);
75+
const keyPairs = await getKeyPairs(userId, oldPassword);
7576

7677
for (let i = 0; i < keyPairs.length; i++) {
7778
const keyPair = keyPairs[i];
@@ -89,7 +90,7 @@ export const changeDecryptionPassword = async (
8990
});
9091
}
9192

92-
return await getKeyPairs(userId);
93+
return await getKeyPairs(userId, newPassword);
9394
};
9495

9596
// Decrypt user's private key
@@ -141,6 +142,7 @@ export const decryptPrivateKey = async (
141142
// Delete encrypted private keys
142143
export const deleteEncryptedPrivateKeys = async (
143144
user_id: string,
145+
decryptPassword: string | null,
144146
organization_id?: string | null,
145147
) => {
146148
const prisma = getPrismaClient();
@@ -149,7 +151,7 @@ export const deleteEncryptedPrivateKeys = async (
149151
user_id,
150152
};
151153

152-
await extendWhere(where, organization_id);
154+
await extendWhere(where, decryptPassword, organization_id);
153155

154156
await prisma.keyPair.updateMany({
155157
where,
@@ -160,14 +162,14 @@ export const deleteEncryptedPrivateKeys = async (
160162
};
161163

162164
// Clear user's keys
163-
export const deleteSecretHashes = async (user_id: string, organization_id?: string | null) => {
165+
export const deleteSecretHashes = async (user_id: string, decryptPassword: string | null, organization_id?: string | null) => {
164166
const prisma = getPrismaClient();
165167

166168
const where: Prisma.KeyPairWhereInput = {
167169
user_id,
168170
};
169171

170-
await extendWhere(where, organization_id);
172+
await extendWhere(where, decryptPassword, organization_id);
171173

172174
await prisma.keyPair.deleteMany({
173175
where,
@@ -215,7 +217,7 @@ export const updateIndex = async (keyPairId: string, index: number) => {
215217
});
216218
};
217219

218-
async function extendWhere(where: Prisma.KeyPairWhereInput, organization_id?: string | null) {
220+
async function extendWhere(where: Prisma.KeyPairWhereInput, decryptPassword: string | null, organization_id?: string | null) {
219221
if (organization_id !== undefined) {
220222
if (organization_id === null) {
221223
where.organization_id = null;
@@ -225,7 +227,7 @@ async function extendWhere(where: Prisma.KeyPairWhereInput, organization_id?: st
225227
const organization = await getOrganization(organization_id);
226228

227229
if (organization) {
228-
const tokenPayload = await getCurrentUser(organization.serverUrl);
230+
const tokenPayload = await getCurrentUser(organization.serverUrl, decryptPassword);
229231

230232
where.organization_id = organization.id;
231233

front-end/src/main/services/localUser/organizationCredentials.ts

Lines changed: 94 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,32 +8,45 @@ import { login } from '@main/services/organization/auth';
88
import { getUseKeychainClaim } from '@main/services/localUser/claim';
99

1010
import { createLogger } from '@main/modules/logger';
11-
import { decrypt, encrypt, isLegacyBlob } from '@main/utils/crypto';
11+
import {
12+
decrypt,
13+
encrypt,
14+
isClearTextToken,
15+
isLegacyBlob,
16+
} from '@main/utils/crypto';
1217

1318
const logger = createLogger('main.organizationCredentials');
1419

1520
/* Returns the organization that the user is connected to */
16-
export const getOrganizationTokens = async (user_id: string) => {
21+
export const getOrganizationTokens = async (user_id: string, decryptPassword: string | null) => {
1722
const prisma = getPrismaClient();
1823

1924
try {
2025
const orgs = await prisma.organizationCredentials.findMany({
2126
where: { user_id },
2227
select: {
28+
id: true,
2329
organization_id: true,
2430
jwtToken: true,
2531
},
2632
});
33+
const result: { organization_id: string; jwtToken: string | null }[] = [];
34+
for (const o of orgs) {
35+
result.push({
36+
organization_id: o.organization_id,
37+
jwtToken: await decryptMigrateJwtToken(o, decryptPassword),
38+
});
39+
}
40+
return result;
2741

28-
return orgs || [];
2942
} catch (error) {
3043
logger.error('Failed to get organization tokens', { error });
3144
return [];
3245
}
3346
};
3447

3548
/* Returns the organizations that the user should sign into */
36-
export const organizationsToSignIn = async (user_id: string) => {
49+
export const organizationsToSignIn = async (user_id: string, decryptPassword: string | null) => {
3750
const prisma = getPrismaClient();
3851

3952
try {
@@ -47,7 +60,7 @@ export const organizationsToSignIn = async (user_id: string) => {
4760
const finalCredentials: typeof credentials = [];
4861

4962
for (let i = 0; i < credentials.length; i++) {
50-
if (await organizationCredentialsInvalid(credentials[i]))
63+
if (await organizationCredentialsInvalid(credentials[i], decryptPassword))
5164
finalCredentials.push(credentials[i]);
5265
}
5366

@@ -59,7 +72,7 @@ export const organizationsToSignIn = async (user_id: string) => {
5972
};
6073

6174
/* Returns whether the user should sign in a specific organization */
62-
export const shouldSignInOrganization = async (user_id: string, organization_id: string) => {
75+
export const shouldSignInOrganization = async (user_id: string, organization_id: string, decryptPassword: string | null) => {
6376
const prisma = getPrismaClient();
6477

6578
try {
@@ -70,31 +83,31 @@ export const shouldSignInOrganization = async (user_id: string, organization_id:
7083
},
7184
});
7285

73-
return await organizationCredentialsInvalid(org);
86+
return await organizationCredentialsInvalid(org, decryptPassword);
7487
} catch {
7588
return true;
7689
}
7790
};
7891

7992
/* Returns the access token of a user for an organization */
80-
export const getAccessToken = async (serverUrl: string) => {
93+
export const getAccessToken = async (serverUrl: string, decryptPassword: string | null) => {
8194
const prisma = getPrismaClient();
8295

8396
try {
8497
const credentials = await prisma.organizationCredentials.findFirst({
8598
where: { organization: { serverUrl } },
8699
});
87100
if (!credentials) return null;
88-
return credentials.jwtToken || null;
101+
return await decryptMigrateJwtToken(credentials, decryptPassword);
89102
} catch (error) {
90103
logger.error('Failed to get access token', { error });
91104
return null;
92105
}
93106
};
94107

95108
/* Returns the current user of an organization */
96-
export const getCurrentUser = async (organizationServerUrl: string) => {
97-
const token = await getAccessToken(organizationServerUrl);
109+
export const getCurrentUser = async (organizationServerUrl: string, decryptPassword: string | null) => {
110+
const token = await getAccessToken(organizationServerUrl, decryptPassword);
98111
if (!token) return null;
99112

100113
try {
@@ -105,6 +118,7 @@ export const getCurrentUser = async (organizationServerUrl: string) => {
105118
}
106119
};
107120

121+
108122
/* Returns credentials for organization */
109123
export const getOrganizationCredentials = async (
110124
organization_id: string,
@@ -120,11 +134,13 @@ export const getOrganizationCredentials = async (
120134

121135
if (!credentials) return null;
122136

123-
const password = await decryptData(credentials.password, decryptPassword, credentials.id);
137+
const password = await decryptMigratePassword(credentials, decryptPassword);
138+
const jwtToken = await decryptMigrateJwtToken(credentials, decryptPassword);
124139

125140
return {
126141
...credentials,
127142
password,
143+
jwtToken,
128144
};
129145
} catch (error) {
130146
logger.error('Failed to get organization credentials', { error });
@@ -178,6 +194,7 @@ export const addOrganizationCredentials = async (
178194

179195
try {
180196
password = await encryptData(password, encryptPassword);
197+
jwtToken = await encryptData(jwtToken, encryptPassword);
181198

182199
await prisma.organizationCredentials.create({
183200
data: {
@@ -213,6 +230,10 @@ export const updateOrganizationCredentials = async (
213230
password = await encryptData(password, encryptPassword);
214231
}
215232

233+
if (jwtToken && !passwordIsEncrypted) {
234+
jwtToken = await encryptData(jwtToken, encryptPassword);
235+
}
236+
216237
const credentials = await prisma.organizationCredentials.findFirst({
217238
where: { user_id, organization_id },
218239
});
@@ -258,7 +279,7 @@ export const deleteOrganizationCredentials = async (organization_id: string, use
258279
export const tryAutoSignIn = async (user_id: string, decryptPassword: string | null) => {
259280
const prisma = getPrismaClient();
260281

261-
const invalidCredentials = await organizationsToSignIn(user_id);
282+
const invalidCredentials = await organizationsToSignIn(user_id, decryptPassword);
262283

263284
const failedLogins: Organization[] = [];
264285

@@ -267,7 +288,10 @@ export const tryAutoSignIn = async (user_id: string, decryptPassword: string | n
267288

268289
let password = '';
269290
try {
270-
password = await decryptData(invalidCredential.password, decryptPassword, invalidCredential.id);
291+
password = await decryptMigratePassword(
292+
invalidCredential,
293+
decryptPassword,
294+
);
271295
} catch {
272296
throw new Error('Incorrect decryption password');
273297
}
@@ -278,10 +302,11 @@ export const tryAutoSignIn = async (user_id: string, decryptPassword: string | n
278302
invalidCredential.email,
279303
password,
280304
);
305+
const encryptedAccessToken = await encryptData(accessToken, decryptPassword);
281306

282307
await prisma.organizationCredentials.update({
283308
where: { id: invalidCredential.id },
284-
data: { jwtToken: accessToken },
309+
data: { jwtToken: encryptedAccessToken },
285310
});
286311
} catch {
287312
failedLogins.push(invalidCredential.organization);
@@ -348,8 +373,7 @@ async function encryptData(data: string, encryptPassword?: string | null) {
348373
/* Decrypt data */
349374
export async function decryptData(
350375
data: string,
351-
decryptPassword?: string | null,
352-
credentialId?: string,
376+
decryptPassword: string | null,
353377
) {
354378
// if no data was stored (password cleared), just return empty string
355379
if (data.length === 0) {
@@ -361,11 +385,32 @@ export async function decryptData(
361385
const buffer = Buffer.from(data, 'base64');
362386
return safeStorage.decryptString(buffer);
363387
} else if (decryptPassword) {
364-
const decrypted = await decrypt(data, decryptPassword);
365-
if (isLegacyBlob(data) && credentialId) {
388+
return decrypt(data, decryptPassword);
389+
} else {
390+
throw new Error('Password is required to decrypt sensitive');
391+
}
392+
}
393+
394+
/* Decrypt credentials password. Update its encryption if needed. */
395+
export async function decryptMigratePassword(
396+
credential: { id: string, password: string },
397+
decryptPassword: string | null,
398+
) {
399+
// if password was cleared, just return empty string
400+
if (credential.password.length === 0) {
401+
return '';
402+
}
403+
404+
const useKeychain = await getUseKeychainClaim();
405+
if (useKeychain) {
406+
const buffer = Buffer.from(credential.password, 'base64');
407+
return safeStorage.decryptString(buffer);
408+
} else if (decryptPassword) {
409+
const decrypted = await decrypt(credential.password, decryptPassword);
410+
if (isLegacyBlob(credential.password)) {
366411
try {
367412
await getPrismaClient().organizationCredentials.update({
368-
where: { id: credentialId },
413+
where: { id: credential.id },
369414
data: { password: await encrypt(decrypted, decryptPassword) },
370415
});
371416
} catch {
@@ -378,15 +423,42 @@ export async function decryptData(
378423
}
379424
}
380425

426+
/* Decrypt credentials JWT token. Update its encryption if needed. */
427+
export async function decryptMigrateJwtToken(
428+
credential: { id: string; jwtToken: string | null},
429+
decryptPassword: string | null,
430+
) {
431+
// if token is null, returns null
432+
if (credential.jwtToken === null) {
433+
return null;
434+
}
435+
436+
if (isClearTextToken(credential.jwtToken)) {
437+
// JWT token is not encrypted => we encrypt it
438+
try {
439+
await getPrismaClient().organizationCredentials.update({
440+
where: { id: credential.id },
441+
data: { jwtToken: await encryptData(credential.jwtToken, decryptPassword) },
442+
});
443+
} catch {
444+
// migration failure is non-fatal
445+
}
446+
return credential.jwtToken;
447+
} else {
448+
return decryptData(credential.jwtToken, decryptPassword);
449+
}
450+
}
451+
381452
/* Validate organization credentials */
382453
export async function organizationCredentialsInvalid(
383-
org?: (OrganizationCredentials & { organization: Organization }) | null,
454+
org: (OrganizationCredentials & { organization: Organization }) | null,
455+
decryptPassword: string | null,
384456
) {
385457
if (!org) return true;
386458

387459
if (org.password.length === 0 || org.email.length === 0) return true;
388460

389-
const token = await getAccessToken(org.organization.serverUrl);
461+
const token = await getAccessToken(org.organization.serverUrl, decryptPassword);
390462
if (!token) return true;
391463

392464
try {

front-end/src/main/services/localUser/transactions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export const signTransaction = async (
101101
transaction.freezeWith(client);
102102
}
103103

104-
const keyPairs = await getKeyPairs(userId);
104+
const keyPairs = await getKeyPairs(userId, userPassword);
105105

106106
const useKeychain = await getUseKeychainClaim();
107107

front-end/src/main/utils/crypto.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ export function isLegacyBlob(data: string) {
3030
return !data.startsWith(BLOB_V2_PREFIX);
3131
}
3232

33+
export function isClearTextToken(jwtData: string): boolean {
34+
return /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/.test(jwtData);
35+
}
36+
3337
export async function encrypt(data: string, password: string): Promise<string> {
3438
const iv = crypto.randomBytes(16);
3539
const salt = crypto.randomBytes(64);

0 commit comments

Comments
 (0)