Non-encrypted fields are overwritten by stale values from `__encryptedData` on read
- Dominant language
- JavaScript
- Stars
- 29
- Forks
- 23
- PR merge metrics
- No merged PRs in 30d
Description
`encryptEntity` calls `performEncryption(encryptionKey, entity, nonceOverride)` instead of `performEncryption(encryptionKey, toEncrypt, nonceOverride)`
The result of this gets set as the `__encryptedData` on the record and then when the record is decrypted and [reconstructed here](https://github.com/dexie/dexie-encrypted/blob/c0bff8145dc51e74a9794f9b14cb5220e30c2e32/src/installHooks.ts#L97-L100), the decrypted data is applied after the unencrypted data.
The end result of this is if you change an unencrypted column using IndexedDB directly, any logic that uses Dexie to read will never see that updated value because Dexie will decrypt the record and overwrite the updated column with what was in the encrypted data.
This seems like unintended behavior given the encryption logic actually builds an object that contains the columns and values to encrypt. But then it does nothing with it. If it is intended behavior, it would be nice if it was documented. I was very surprised by the behavior.
Here's a simple test that demonstrates the issue:
```
import Dexie, { EntityTable } from 'dexie';
import { applyEncryptionMiddleware, cryptoOptions } from 'dexie-encrypted';
type TestRecord = {
id: string;
plainField: number;
encryptedField: string;
};
type TestDb = Dexie & {
items: EntityTable;
};
const KEY = new Uint8Array(32).fill(0xab);
function rawGet(db: TestDb, key: string): Promise> {
const idb = db.backendDB();
return new Promise((resolve, reject) => {
const tx = idb.transaction('items', 'readonly');
const req = tx.objectStore('items').get(key);
req.onsuccess = () => resolve(req.result);
tx.onerror = () => reject(tx.error);
});
}
function rawPut(db: TestDb, record: Record): Promise {
const idb = db.backendDB();
return new Promise((resolve, reject) => {
const tx = idb.transaction('items', 'readwrite');
tx.objectStore('items').put(record);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
let dbCounter = 0;
async function createDb(): Promise {
const db = new Dexie(`test-encrypt-${++dbCounter}`) as TestDb;
db.version(1).stores({ items: 'id' });
applyEncryptionMiddleware(db, KEY, {
items: {
type: cryptoOptions.ENCRYPT_LIST,
fields: ['encryptedField'],
},
}, async () => {});
await db.open();
return db;
}
describe('dexie-encrypted ENCRYPT_LIST overwrites non-encrypted fields on read', () => {
let db: TestDb;
afterEach(() => {
db?.close();
});
it('should read back raw IDB changes to non-encrypted fields', async () => {
db = await createDb();
// Write through Dexie so __encryptedData is generated
await db.items.put({ id: 'a', plainField: 100, encryptedField: 'secret' });
// Modify only the non-encrypted field via raw IDB
const raw = await rawGet(db, 'a');
raw.plainField = 999;
await rawPut(db, raw);
// Verify raw IDB has the new value
const rawAfter = await rawGet(db, 'a');
expect(rawAfter.plainField).toBe(999);
// Dexie should return 999 since plainField is not in the encrypted fields list.
// BUG: it returns 100 because __encryptedData contains the full original entity
// and the decrypted values overwrite the plaintext fields on read.
const dexieRead = await db.items.get('a');
expect(dexieRead?.plainField).toBe(999);
});
});
```
Contributor guide
No contributing guide indexed for this repository
Research direction
Start at encryptEntity and inspect the reconstruction in src/installHooks.ts around lines 97-100, then run the supplied regression test against a raw IndexedDB update. Done means a changed non-encrypted field remains visible through Dexie while encrypted fields continue to decrypt correctly.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, typescript
- Domain
- database, security
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 74/100