Skip to content

feat: reduce KDF computing cost on clients #6097

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
fb4d0ad
feat: dumb down user storage KDF
mathieuartu Jul 10, 2025
cf2a645
Merge branch 'main' into feat/kdf-performance-2-dumbed-down
mathieuartu Jul 10, 2025
2bb8758
refresh hash
mathieuartu Jul 10, 2025
8ebaf2b
fix: stay backwards compatible
mathieuartu Jul 11, 2025
58eee07
feat: add encryption callbacks
mathieuartu Jul 11, 2025
5a7db01
Merge branch 'main' into feat/kdf-performance-2-dumbed-down
mathieuartu Jul 11, 2025
7b31ece
fix: update changelog
mathieuartu Jul 11, 2025
ce8cc96
Merge branch 'main' into feat/kdf-performance-2-dumbed-down
mathieuartu Jul 16, 2025
ea7474e
fix: better changelog entry
mathieuartu Jul 16, 2025
6897586
Merge branch 'main' into feat/kdf-performance-2-dumbed-down
mathieuartu Jul 16, 2025
a384bb9
fix: add more precision to the KDF cache index
mathieuartu Jul 16, 2025
c2d112d
fix: add cache index UT for entries with same password but different …
mathieuartu Jul 17, 2025
f4a7cba
Update packages/profile-sync-controller/src/shared/encryption/encrypt…
mathieuartu Jul 17, 2025
78a4fef
fix: do not hash cache indexes
mathieuartu Jul 17, 2025
052e332
fix: cursor feedback
mathieuartu Jul 17, 2025
7205afb
Merge branch 'main' into feat/kdf-performance-2-dumbed-down
mathieuartu Jul 17, 2025
f584cf0
fix: use salt_v2 to prevent cache collisions
mathieuartu Jul 17, 2025
ffa6d7a
Merge branch 'feat/kdf-performance-2-dumbed-down' of github.com:MetaM…
mathieuartu Jul 17, 2025
227bac3
fix: UTs
mathieuartu Jul 17, 2025
00abdb8
fix: lint
mathieuartu Jul 17, 2025
1982229
fix: tests
mathieuartu Jul 18, 2025
7b3f699
fix: lint
mathieuartu Jul 18, 2025
03ea854
fix: add specificity to cache indexes
mathieuartu Jul 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/profile-sync-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Dumb down the `N` `scrypt` parameter in order to increase performance on clients
- This decreases the impact of the KDF on all clients to around 99%
- Add backwards compatible migration logic
- Add encryption callbacks so we can measure the impact of future migrations through analytics

## [21.0.0]

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ import * as AccountSyncControllerIntegrationModule from './account-syncing/contr
import { BACKUPANDSYNC_FEATURES } from './constants';
import { MOCK_STORAGE_DATA, MOCK_STORAGE_KEY } from './mocks/mockStorage';
import UserStorageController, { defaultState } from './UserStorageController';
import {
ALGORITHM_KEY_SIZE,
SCRYPT_N_V2,
SCRYPT_p,
SCRYPT_r,
SCRYPT_SALT_SIZE,
} from '../../shared/encryption/constants';
import { USER_STORAGE_FEATURE_NAMES } from '../../shared/storage-schema';

describe('user-storage/user-storage-controller - constructor() tests', () => {
Expand Down Expand Up @@ -936,3 +943,59 @@ describe('user-storage/user-storage-controller - snap handling', () => {
expect(await controller.getStorageKey()).toBe(MOCK_STORAGE_KEY);
});
});

describe('user-storage/user-storage-controller - encryption callbacks', () => {
const arrangeMocks = async () => {
const messengerMocks = mockUserStorageMessenger();
const mockOnDecrypt = jest.fn();
const mockOnEncrypt = jest.fn();
const controller = new UserStorageController({
messenger: messengerMocks.messenger,
config: {
encryption: {
onDecrypt: mockOnDecrypt,
onEncrypt: mockOnEncrypt,
},
},
});

await Promise.all([
mockEndpointGetUserStorage(
`${USER_STORAGE_FEATURE_NAMES.notifications}.notification_settings`,
),
mockEndpointUpsertUserStorage(
`${USER_STORAGE_FEATURE_NAMES.notifications}.notification_settings`,
),
]);

return { mockOnDecrypt, mockOnEncrypt, controller };
};

it('calls encryption callbacks if they are set', async () => {
const { mockOnEncrypt, mockOnDecrypt, controller } = await arrangeMocks();

await controller.performSetStorage(
`${USER_STORAGE_FEATURE_NAMES.notifications}.notification_settings`,
'new data',
);

await controller.performGetStorage(
`${USER_STORAGE_FEATURE_NAMES.notifications}.notification_settings`,
);

const scryptParams = {
o: {
N: SCRYPT_N_V2,
dkLen: ALGORITHM_KEY_SIZE,
p: SCRYPT_p,
r: SCRYPT_r,
},
saltLen: SCRYPT_SALT_SIZE,
t: 'scrypt',
v: '1',
};

expect(mockOnEncrypt).toHaveBeenCalledWith(scryptParams);
expect(mockOnDecrypt).toHaveBeenCalledWith(scryptParams);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { BACKUPANDSYNC_FEATURES } from './constants';
import { syncContactsWithUserStorage } from './contact-syncing/controller-integration';
import { setupContactSyncingSubscriptions } from './contact-syncing/setup-subscriptions';
import type {
EncryptedPayload,
UserStorageGenericFeatureKey,
UserStorageGenericPathWithFeatureAndKey,
UserStorageGenericPathWithFeatureOnly,
Expand Down Expand Up @@ -147,6 +148,10 @@ const metadata: StateMetadata<UserStorageControllerState> = {

type ControllerConfig = {
env: Env;
encryption?: {
onEncrypt?: (encryptedData: Omit<EncryptedPayload, 'd'>) => Promise<void>;
onDecrypt?: (encryptedData: Omit<EncryptedPayload, 'd'>) => Promise<void>;
};
accountSyncing?: {
maxNumberOfAccountsToAdd?: number;
/**
Expand Down Expand Up @@ -490,6 +495,8 @@ export default class UserStorageController extends BaseController<
return await this.#userStorage.getItem(path, {
nativeScryptCrypto: this.#nativeScryptCrypto,
entropySourceId,
onDecrypt: this.#config.encryption?.onDecrypt,
onEncrypt: this.#config.encryption?.onEncrypt,
});
}

Expand All @@ -508,6 +515,8 @@ export default class UserStorageController extends BaseController<
return await this.#userStorage.getAllFeatureItems(path, {
nativeScryptCrypto: this.#nativeScryptCrypto,
entropySourceId,
onDecrypt: this.#config.encryption?.onDecrypt,
onEncrypt: this.#config.encryption?.onEncrypt,
});
}

Expand All @@ -528,6 +537,8 @@ export default class UserStorageController extends BaseController<
return await this.#userStorage.setItem(path, value, {
nativeScryptCrypto: this.#nativeScryptCrypto,
entropySourceId,
onDecrypt: this.#config.encryption?.onDecrypt,
onEncrypt: this.#config.encryption?.onEncrypt,
});
}

Expand All @@ -548,6 +559,8 @@ export default class UserStorageController extends BaseController<
return await this.#userStorage.batchSetItems(path, values, {
nativeScryptCrypto: this.#nativeScryptCrypto,
entropySourceId,
onDecrypt: this.#config.encryption?.onDecrypt,
onEncrypt: this.#config.encryption?.onEncrypt,
});
}

Expand Down
97 changes: 87 additions & 10 deletions packages/profile-sync-controller/src/sdk/user-storage.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import type { UserStorageGenericFeatureKey } from 'src/shared/storage-schema';

import { arrangeAuthAPIs } from './__fixtures__/auth';
import { arrangeAuth, typedMockFn } from './__fixtures__/test-utils';
import {
Expand All @@ -20,8 +18,9 @@ import {
import type { StorageOptions } from './user-storage';
import { STORAGE_URL, UserStorage } from './user-storage';
import encryption, { createSHA256Hash } from '../shared/encryption';
import { SHARED_SALT } from '../shared/encryption/constants';
import { SCRYPT_N_V2, SHARED_SALT } from '../shared/encryption/constants';
import { Env } from '../shared/env';
import type { UserStorageGenericFeatureKey } from '../shared/storage-schema';
import { USER_STORAGE_FEATURE_NAMES } from '../shared/storage-schema';

const MOCK_SRP = '0x6265617665726275696c642e6f7267';
Expand Down Expand Up @@ -130,6 +129,43 @@ describe('User Storage', () => {
expect(mockPut.isDone()).toBe(true);
});

it('re-encrypts data if received entry was encrypted with the old scrypt N param, and saves it back to user storage', async () => {
const { auth } = arrangeAuth('SRP', MOCK_SRP);
const { userStorage } = arrangeUserStorage(auth);

// This corresponds to 'data1'
// Encrypted with the old scrypt N param
const mockResponse = {
HashedKey: 'entry1',
Data: '{"v":"1","t":"scrypt","d":"HIu+WgFBCtKo6rEGy0R8h8t/JgXhzC2a3AF6epahGY2h6GibXDKxSBf6ppxM099Gmg==","o":{"N":131072,"r":8,"p":1,"dkLen":16},"saltLen":16}',
};

const mockGet = await handleMockUserStorageGet({
status: 200,
body: JSON.stringify(mockResponse),
});
const mockPut = handleMockUserStoragePut(
undefined,
async (_, requestBody) => {
// eslint-disable-next-line jest/no-conditional-in-test
if (typeof requestBody === 'string') {
return;
}

const isEncryptedUsingNewScryptN =
JSON.parse(requestBody.data).o.N === SCRYPT_N_V2;

expect(isEncryptedUsingNewScryptN).toBe(true);
},
);

await userStorage.getItem(
`${USER_STORAGE_FEATURE_NAMES.notifications}.notification_settings`,
);
expect(mockGet.isDone()).toBe(true);
expect(mockPut.isDone()).toBe(true);
});

it('gets all feature entries', async () => {
const { auth } = arrangeAuth('SRP', MOCK_SRP);
const { userStorage } = arrangeUserStorage(auth);
Expand Down Expand Up @@ -184,13 +220,6 @@ describe('User Storage', () => {
return;
}

const doEntriesHaveDifferentSalts =
encryption.getIfEntriesHaveDifferentSalts(
Object.entries(requestBody.data).map((entry) => entry[1] as string),
);

expect(doEntriesHaveDifferentSalts).toBe(false);

const doEntriesUseSharedSalt = Object.entries(requestBody.data).every(
([_entryKey, entryValue]) =>
encryption.getSalt(entryValue as string).toString() ===
Expand All @@ -213,6 +242,54 @@ describe('User Storage', () => {
expect(mockPut.isDone()).toBe(true);
});

it('re-encrypts data if received entries were encrypted with the old scrypt N param, and saves it back to user storage', async () => {
// This corresponds to [['entry1', 'data1'], ['entry2', 'data2'], ['HASHED_KEY', '{ "hello": "world" }']]
// Each entry has been encrypted with a random salt, except for the last entry
// The last entry is used to test if the function can handle payloads that contain both random salts and the shared salt
const mockResponse = [
{
HashedKey: 'entry1',
Data: '{"v":"1","t":"scrypt","d":"HIu+WgFBCtKo6rEGy0R8h8t/JgXhzC2a3AF6epahGY2h6GibXDKxSBf6ppxM099Gmg==","o":{"N":131072,"r":8,"p":1,"dkLen":16},"saltLen":16}',
},
{
HashedKey: 'entry2',
Data: '{"v":"1","t":"scrypt","d":"3ioo9bxhjDjTmJWIGQMnOlnfa4ysuUNeLYTTmJ+qrq7gwI6hURH3ooUcBldJkHtvuQ==","o":{"N":131072,"r":8,"p":1,"dkLen":16},"saltLen":16}',
},
await MOCK_STORAGE_RESPONSE('data3'),
];

const { auth } = arrangeAuth('SRP', MOCK_SRP);
const { userStorage } = arrangeUserStorage(auth);

const mockGetAll = await handleMockUserStorageGetAllFeatureEntries({
status: 200,
body: mockResponse,
});

const mockPut = handleMockUserStoragePut(
undefined,
async (_, requestBody) => {
// eslint-disable-next-line jest/no-conditional-in-test
if (typeof requestBody === 'string') {
return;
}

const doEntriesUseNewScryptN = Object.entries(requestBody.data).every(
([_entryKey, entryValue]) =>
JSON.parse(entryValue as string).o.N === SCRYPT_N_V2,
);

expect(doEntriesUseNewScryptN).toBe(true);
},
);

await userStorage.getAllFeatureItems(
USER_STORAGE_FEATURE_NAMES.notifications,
);
expect(mockGetAll.isDone()).toBe(true);
expect(mockPut.isDone()).toBe(true);
});

it('batch set items', async () => {
const dataToStore: [UserStorageGenericFeatureKey, string][] = [
['0x123', JSON.stringify(MOCK_NOTIFICATIONS_DATA)],
Expand Down
63 changes: 32 additions & 31 deletions packages/profile-sync-controller/src/sdk/user-storage.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { IBaseAuth } from './authentication-jwt-bearer/types';
import { NotFoundError, UserStorageError } from './errors';
import type { EncryptedPayload } from '../shared/encryption';
import encryption, { createSHA256Hash } from '../shared/encryption';
import { SHARED_SALT } from '../shared/encryption/constants';
import type { Env } from '../shared/env';
import { getEnvUrls } from '../shared/env';
import type {
Expand Down Expand Up @@ -39,6 +39,8 @@ export type GetUserStorageAllFeatureEntriesResponse = {
export type UserStorageMethodOptions = {
nativeScryptCrypto?: NativeScrypt;
entropySourceId?: string;
onEncrypt?: (encryptedData: Omit<EncryptedPayload, 'd'>) => Promise<void>;
onDecrypt?: (encryptedData: Omit<EncryptedPayload, 'd'>) => Promise<void>;
};

type ErrorMessage = {
Expand Down Expand Up @@ -141,11 +143,10 @@ export class UserStorage {
try {
const headers = await this.#getAuthorizationHeader(entropySourceId);
const storageKey = await this.getStorageKey(entropySourceId);
const encryptedData = await encryption.encryptString(
data,
storageKey,
options?.nativeScryptCrypto,
);
const encryptedData = await encryption.encryptString(data, storageKey, {
nativeScryptCrypto: options?.nativeScryptCrypto,
onEncrypt: options?.onEncrypt,
});
const encryptedPath = createEntryPath(path, storageKey);

const url = new URL(STORAGE_URL(this.env, encryptedPath));
Expand Down Expand Up @@ -196,11 +197,10 @@ export class UserStorage {
data.map(async (d) => {
return [
this.#createEntryKey(d[0], storageKey),
await encryption.encryptString(
d[1],
storageKey,
options?.nativeScryptCrypto,
),
await encryption.encryptString(d[1], storageKey, {
nativeScryptCrypto: options?.nativeScryptCrypto,
onEncrypt: options?.onEncrypt,
}),
];
}),
);
Expand Down Expand Up @@ -315,12 +315,14 @@ export class UserStorage {
const decryptedData = await encryption.decryptString(
encryptedData,
storageKey,
options?.nativeScryptCrypto,
{
nativeScryptCrypto: options?.nativeScryptCrypto,
onDecrypt: options?.onDecrypt,
},
);

// Re-encrypt the entry if it was encrypted with a random salt
const salt = encryption.getSalt(encryptedData);
if (salt.toString() !== SHARED_SALT.toString()) {
// Data migration
if (encryption.doesEntryNeedReEncryption(encryptedData)) {
await this.#upsertUserStorage(path, decryptedData, options);
}

Expand Down Expand Up @@ -381,24 +383,23 @@ export class UserStorage {
}

try {
const data = await encryption.decryptString(
entry.Data,
storageKey,
options?.nativeScryptCrypto,
);
const data = await encryption.decryptString(entry.Data, storageKey, {
nativeScryptCrypto: options?.nativeScryptCrypto,
onDecrypt: options?.onDecrypt,
});
decryptedData.push(data);

// Re-encrypt the entry was encrypted with a random salt
const salt = encryption.getSalt(entry.Data);
if (salt.toString() !== SHARED_SALT.toString()) {
reEncryptedEntries.push([
entry.HashedKey,
await encryption.encryptString(
data,
storageKey,
options?.nativeScryptCrypto,
),
]);
// Data migration
if (encryption.doesEntryNeedReEncryption(entry.Data)) {
const reEncryptedData = await encryption.encryptString(
data,
storageKey,
{
nativeScryptCrypto: options?.nativeScryptCrypto,
onEncrypt: options?.onEncrypt,
},
);
reEncryptedEntries.push([entry.HashedKey, reEncryptedData]);
}
} catch {
// do nothing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const ALGORITHM_KEY_SIZE = 16; // 16 bytes
// see: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#scrypt
export const SCRYPT_SALT_SIZE = 16; // 16 bytes
export const SCRYPT_N = 2 ** 17; // CPU/memory cost parameter (must be a power of 2, > 1)
export const SCRYPT_N_V2 = 2;
export const SCRYPT_r = 8; // Block size parameter
export const SCRYPT_p = 1; // Parallelization parameter

Expand Down
Loading
Loading