@proteinjs/db-driver-spanner 1.23.5 → 1.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/dist/generated/test/index.d.ts.map +1 -1
- package/dist/generated/test/index.js +15 -1
- package/dist/generated/test/index.js.map +1 -1
- package/dist/src/SpannerSchemaOperations.d.ts.map +1 -1
- package/dist/src/SpannerSchemaOperations.js +4 -1
- package/dist/src/SpannerSchemaOperations.js.map +1 -1
- package/dist/test/ColumnEncryption.test.d.ts +2 -0
- package/dist/test/ColumnEncryption.test.d.ts.map +1 -0
- package/dist/test/ColumnEncryption.test.js +482 -0
- package/dist/test/ColumnEncryption.test.js.map +1 -0
- package/dist/test/EncryptedSearch.test.d.ts +2 -0
- package/dist/test/EncryptedSearch.test.d.ts.map +1 -0
- package/dist/test/EncryptedSearch.test.js +455 -0
- package/dist/test/EncryptedSearch.test.js.map +1 -0
- package/dist/test/EncryptionLifecycleWalker.test.d.ts +2 -0
- package/dist/test/EncryptionLifecycleWalker.test.d.ts.map +1 -0
- package/dist/test/EncryptionLifecycleWalker.test.js +408 -0
- package/dist/test/EncryptionLifecycleWalker.test.js.map +1 -0
- package/dist/test/EncryptionPerf.test.d.ts +2 -0
- package/dist/test/EncryptionPerf.test.d.ts.map +1 -0
- package/dist/test/EncryptionPerf.test.js +278 -0
- package/dist/test/EncryptionPerf.test.js.map +1 -0
- package/dist/test/EncryptionSortAndUnique.test.d.ts +2 -0
- package/dist/test/EncryptionSortAndUnique.test.d.ts.map +1 -0
- package/dist/test/EncryptionSortAndUnique.test.js +229 -0
- package/dist/test/EncryptionSortAndUnique.test.js.map +1 -0
- package/dist/test/index.d.ts +1 -0
- package/dist/test/index.d.ts.map +1 -1
- package/dist/test/index.js +1 -0
- package/dist/test/index.js.map +1 -1
- package/dist/test/util/columnEncryptionTestHarness.d.ts +23 -0
- package/dist/test/util/columnEncryptionTestHarness.d.ts.map +1 -0
- package/dist/test/util/columnEncryptionTestHarness.js +136 -0
- package/dist/test/util/columnEncryptionTestHarness.js.map +1 -0
- package/dist/test/util/columnEncryptionTestTables.d.ts +75 -0
- package/dist/test/util/columnEncryptionTestTables.d.ts.map +1 -0
- package/dist/test/util/columnEncryptionTestTables.js +124 -0
- package/dist/test/util/columnEncryptionTestTables.js.map +1 -0
- package/dist/test/util/serviceUpdateVerbsTestTables.d.ts +2 -2
- package/generated/test/index.ts +15 -1
- package/package.json +6 -6
- package/src/SpannerSchemaOperations.ts +4 -1
- package/test/ColumnEncryption.test.ts +252 -0
- package/test/EncryptedSearch.test.ts +247 -0
- package/test/EncryptionLifecycleWalker.test.ts +223 -0
- package/test/EncryptionPerf.test.ts +156 -0
- package/test/EncryptionSortAndUnique.test.ts +130 -0
- package/test/index.ts +1 -0
- package/test/util/columnEncryptionTestHarness.ts +54 -0
- package/test/util/columnEncryptionTestTables.ts +115 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Db,
|
|
3
|
+
Table,
|
|
4
|
+
setDbEncryptionConfig,
|
|
5
|
+
InMemoryMasterKeyProvider,
|
|
6
|
+
EncryptedColumns,
|
|
7
|
+
QueryBuilder,
|
|
8
|
+
} from '@proteinjs/db';
|
|
9
|
+
import { SpannerDriver } from '@proteinjs/db-driver-spanner';
|
|
10
|
+
import { TransactionContext } from '@proteinjs/db-transaction-context';
|
|
11
|
+
import { getDropTestTable } from './util/getDropTestTable';
|
|
12
|
+
import { SpannerEmulatorProvisioner } from './util/SpannerEmulatorProvisioner';
|
|
13
|
+
import { EncSearchDoc, EncSearchDocTable } from './util/columnEncryptionTestTables';
|
|
14
|
+
import { loadColumnEncryptionTestSchema, purgeColumnEncryptionTestRows } from './util/columnEncryptionTestHarness';
|
|
15
|
+
import { registerTestUser, clearTestUser } from '@proteinjs/db/test';
|
|
16
|
+
import '../generated/test/index';
|
|
17
|
+
|
|
18
|
+
const spannerDriver = new SpannerDriver({
|
|
19
|
+
projectId: 'proteinjs-test',
|
|
20
|
+
instanceName: 'proteinjs-test',
|
|
21
|
+
databaseName: 'test',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
(globalThis as any)['__proteinjs_db_defaultDbDriver'] = spannerDriver;
|
|
25
|
+
|
|
26
|
+
const OWNER_A = 'enc-search-owner-a';
|
|
27
|
+
const OWNER_B = 'enc-search-owner-b';
|
|
28
|
+
|
|
29
|
+
let accessibleOwners: string[] = [OWNER_A];
|
|
30
|
+
|
|
31
|
+
const docTable = new EncSearchDocTable() as Table<EncSearchDoc>;
|
|
32
|
+
const db = new Db(spannerDriver, undefined, new TransactionContext());
|
|
33
|
+
|
|
34
|
+
const titleLike = (pattern: string) =>
|
|
35
|
+
new QueryBuilder<EncSearchDoc>(docTable.name).condition({ field: 'title', operator: 'LIKE', value: pattern });
|
|
36
|
+
|
|
37
|
+
const titlesOf = (rows: EncSearchDoc[]) => rows.map((row) => row.title).sort();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Search exactness over encrypted columns — the recheck contract: the token index answers a
|
|
41
|
+
* candidate set, each candidate is verified against the decrypted value, so NO false
|
|
42
|
+
* positive surfaces and NO true match is missed (query words >= 3 chars); 1-2 character
|
|
43
|
+
* query words behave as word-prefix search (the documented narrowing).
|
|
44
|
+
*/
|
|
45
|
+
describe('Encrypted search: exact contains/prefix, equality, shared-scope OR', () => {
|
|
46
|
+
const dropTable = getDropTestTable(spannerDriver);
|
|
47
|
+
const tableManager = spannerDriver.getTableManager();
|
|
48
|
+
const seeded: string[] = [];
|
|
49
|
+
|
|
50
|
+
const seed = async (scope: string, title: string | null, name?: string | null, kind?: string) => {
|
|
51
|
+
const row = await db.insert(docTable, { scope, title, name: name ?? null, kind: kind ?? 'doc' });
|
|
52
|
+
seeded.push(row.id);
|
|
53
|
+
return row;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
beforeAll(async () => {
|
|
57
|
+
await SpannerEmulatorProvisioner.ensureProvisioned({
|
|
58
|
+
projectId: 'proteinjs-test',
|
|
59
|
+
instanceName: 'proteinjs-test',
|
|
60
|
+
databaseName: 'test',
|
|
61
|
+
});
|
|
62
|
+
registerTestUser();
|
|
63
|
+
setDbEncryptionConfig({
|
|
64
|
+
masterKeyProvider: new InMemoryMasterKeyProvider('column-encryption-test'),
|
|
65
|
+
getAccessibleKeyOwners: async () => accessibleOwners,
|
|
66
|
+
});
|
|
67
|
+
await loadColumnEncryptionTestSchema(tableManager, [docTable]);
|
|
68
|
+
await purgeColumnEncryptionTestRows(spannerDriver, [docTable]);
|
|
69
|
+
|
|
70
|
+
// The corpus: owner A's documents...
|
|
71
|
+
await seed(OWNER_A, 'Groceries list');
|
|
72
|
+
await seed(OWNER_A, 'Grocery budget planning');
|
|
73
|
+
await seed(OWNER_A, 'Pancake factory notes');
|
|
74
|
+
await seed(OWNER_A, 'cake factory shift plan');
|
|
75
|
+
await seed(OWNER_A, 'Therapy notes — divorce');
|
|
76
|
+
await seed(OWNER_A, 'n3xa notes');
|
|
77
|
+
await seed(OWNER_A, null, 'tag-null-title');
|
|
78
|
+
await seed(OWNER_A, 'Once and future factories');
|
|
79
|
+
// holds every fragment of "cake factory" NON-contiguously — a token-cover candidate the
|
|
80
|
+
// verify step must reject
|
|
81
|
+
await seed(OWNER_A, 'factory made cake platter');
|
|
82
|
+
// ...and owner B's (a sharer), including an overlapping word
|
|
83
|
+
await seed(OWNER_B, 'Groceries for the trip');
|
|
84
|
+
await seed(OWNER_B, 'B-only cake recipe');
|
|
85
|
+
}, 120000);
|
|
86
|
+
|
|
87
|
+
afterAll(async () => {
|
|
88
|
+
for (const id of seeded) {
|
|
89
|
+
await db.delete(docTable, { id }).catch(() => undefined);
|
|
90
|
+
}
|
|
91
|
+
setDbEncryptionConfig(undefined);
|
|
92
|
+
clearTestUser();
|
|
93
|
+
await dropTable(new EncryptedColumns().tokenTableFor(docTable)!);
|
|
94
|
+
await dropTable(docTable);
|
|
95
|
+
await SpannerEmulatorProvisioner.release();
|
|
96
|
+
}, 120000);
|
|
97
|
+
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
accessibleOwners = [OWNER_A];
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('contains search is exact: every true match returned, nothing else (the home-search shape)', async () => {
|
|
103
|
+
const rows = await db.query(docTable, titleLike('%Groceries%'));
|
|
104
|
+
expect(titlesOf(rows)).toEqual(['Groceries list']);
|
|
105
|
+
|
|
106
|
+
// multi-word contains, matching across a word prefix ("cake factory" inside "Pancake
|
|
107
|
+
// factory") — and NOT matching 'factory made cake platter', whose token cover holds
|
|
108
|
+
// every fragment non-contiguously (the verify step rejects it)
|
|
109
|
+
const factoryRows = await db.query(docTable, titleLike('%cake factory%'));
|
|
110
|
+
expect(titlesOf(factoryRows)).toEqual(['Pancake factory notes', 'cake factory shift plan']);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('no false positives: rows holding all fragments non-contiguously are rejected by the verify step', async () => {
|
|
114
|
+
// "Once and future factories" holds trigrams of 'fact'/'tor' etc. — but not the
|
|
115
|
+
// contiguous phrase "factory shift"
|
|
116
|
+
const rows = await db.query(docTable, titleLike('%factory shift%'));
|
|
117
|
+
expect(titlesOf(rows)).toEqual(['cake factory shift plan']);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('case-insensitive LIKE (caseSensitive=false) matches across case', async () => {
|
|
121
|
+
const qb = new QueryBuilder<EncSearchDoc>(docTable.name);
|
|
122
|
+
qb.condition({ field: 'title', operator: 'LIKE', value: '%GROCERIES%' }, undefined, false);
|
|
123
|
+
const rows = await db.query(docTable, qb);
|
|
124
|
+
expect(titlesOf(rows)).toEqual(['Groceries list']);
|
|
125
|
+
|
|
126
|
+
// and the default case-sensitive LIKE stays exact about case
|
|
127
|
+
const strict = await db.query(docTable, titleLike('%GROCERIES%'));
|
|
128
|
+
expect(strict.length).toBe(0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('prefix search (q%) matches value starts only', async () => {
|
|
132
|
+
const rows = await db.query(docTable, titleLike('Groc%'));
|
|
133
|
+
expect(titlesOf(rows)).toEqual(['Groceries list', 'Grocery budget planning']);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('1-2 character queries behave as word-prefix search (the documented narrowing)', async () => {
|
|
137
|
+
// 'n' finds "n3xa notes" (word start), and also "notes" words — anything with an n-starting word
|
|
138
|
+
const rows = await db.query(docTable, titleLike('%n%'));
|
|
139
|
+
for (const row of rows) {
|
|
140
|
+
expect(
|
|
141
|
+
(row.title ?? '')
|
|
142
|
+
.toLowerCase()
|
|
143
|
+
.split(/[^a-z0-9]+/)
|
|
144
|
+
.some((word) => word.startsWith('n'))
|
|
145
|
+
).toBe(true);
|
|
146
|
+
}
|
|
147
|
+
expect(titlesOf(rows)).toContain('n3xa notes');
|
|
148
|
+
|
|
149
|
+
// a 1-2 char MID-WORD substring is documented unsupported: 'x' inside 'n3xa' does not match
|
|
150
|
+
const midWord = await db.query(docTable, titleLike('%x%'));
|
|
151
|
+
expect(titlesOf(midWord)).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('composition: encrypted search ANDs with metadata conditions natively', async () => {
|
|
155
|
+
const rows = await db.query(
|
|
156
|
+
docTable,
|
|
157
|
+
titleLike('%Groceries%').condition({ field: 'kind', operator: '=', value: 'doc' })
|
|
158
|
+
);
|
|
159
|
+
expect(titlesOf(rows)).toEqual(['Groceries list']);
|
|
160
|
+
|
|
161
|
+
const noRows = await db.query(
|
|
162
|
+
docTable,
|
|
163
|
+
titleLike('%Groceries%').condition({ field: 'kind', operator: '=', value: 'other' })
|
|
164
|
+
);
|
|
165
|
+
expect(noRows.length).toBe(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('getRowCount over an encrypted search is exact', async () => {
|
|
169
|
+
expect(await db.getRowCount(docTable, titleLike('%cake factory%'))).toBe(2);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('equality lookup rides the whole-value fingerprint: exact, case-exact (the tag get-or-create shape)', async () => {
|
|
173
|
+
await seed(OWNER_A, 'eq holder', 'Divorce');
|
|
174
|
+
await seed(OWNER_A, 'eq holder 2', 'divorce');
|
|
175
|
+
|
|
176
|
+
const exact = await db.get(docTable, { name: 'Divorce' });
|
|
177
|
+
expect(exact.title).toBe('eq holder');
|
|
178
|
+
|
|
179
|
+
const lower = await db.query(docTable, { name: 'divorce' } as any);
|
|
180
|
+
expect(lower.length).toBe(1);
|
|
181
|
+
expect(lower[0].title).toBe('eq holder 2');
|
|
182
|
+
|
|
183
|
+
const missing = await db.get(docTable, { name: 'no-such-tag' });
|
|
184
|
+
expect(missing).toBeFalsy();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('shared-scope search: fingerprints once per accessible owner, ORs the matches', async () => {
|
|
188
|
+
// Only own scope accessible: A sees only A's groceries
|
|
189
|
+
const own = await db.query(docTable, titleLike('%Groceries%'));
|
|
190
|
+
expect(titlesOf(own)).toEqual(['Groceries list']);
|
|
191
|
+
|
|
192
|
+
// B shares with A: the same caller query now covers both owners' keys
|
|
193
|
+
accessibleOwners = [OWNER_A, OWNER_B];
|
|
194
|
+
const shared = await db.query(docTable, titleLike('%Groceries%'));
|
|
195
|
+
expect(titlesOf(shared)).toEqual(['Groceries for the trip', 'Groceries list']);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('token rows follow the row lifecycle: update rewrites them, delete removes them', async () => {
|
|
199
|
+
const row = await db.insert(docTable, {
|
|
200
|
+
scope: OWNER_A,
|
|
201
|
+
title: 'ephemeral zebra thoughts',
|
|
202
|
+
name: null,
|
|
203
|
+
kind: 'doc',
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(titlesOf(await db.query(docTable, titleLike('%zebra%')))).toEqual(['ephemeral zebra thoughts']);
|
|
207
|
+
|
|
208
|
+
await db.update(docTable, { id: row.id, title: 'renamed giraffe thoughts' });
|
|
209
|
+
expect((await db.query(docTable, titleLike('%zebra%'))).length).toBe(0);
|
|
210
|
+
expect(titlesOf(await db.query(docTable, titleLike('%giraffe%')))).toEqual(['renamed giraffe thoughts']);
|
|
211
|
+
|
|
212
|
+
await db.delete(docTable, { id: row.id });
|
|
213
|
+
expect((await db.query(docTable, titleLike('%giraffe%'))).length).toBe(0);
|
|
214
|
+
|
|
215
|
+
// and the token table holds no orphans for the deleted row
|
|
216
|
+
const tokenTable = new EncryptedColumns().tokenTableFor(docTable)!;
|
|
217
|
+
const orphans = await spannerDriver.runQuery(() => ({
|
|
218
|
+
sql: `SELECT COUNT(*) as tokenCount FROM \`${tokenTable.name}\` WHERE \`record_id\` = '${row.id}'`,
|
|
219
|
+
}));
|
|
220
|
+
expect(Number((orphans[0] as any).tokenCount)).toBe(0);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('delete by encrypted search: WHERE title LIKE deletes exactly the true matches', async () => {
|
|
224
|
+
const kept = await db.insert(docTable, { scope: OWNER_A, title: 'wombat keep', name: null, kind: 'doc' });
|
|
225
|
+
const doomed = await db.insert(docTable, { scope: OWNER_A, title: 'wombat purge now', name: null, kind: 'doc' });
|
|
226
|
+
|
|
227
|
+
const deleteCount = await db.delete(docTable, titleLike('%purge now%'));
|
|
228
|
+
expect(deleteCount).toBe(1);
|
|
229
|
+
expect((await db.get(docTable, { id: kept.id })).title).toBe('wombat keep');
|
|
230
|
+
expect(await db.get(docTable, { id: doomed.id })).toBeFalsy();
|
|
231
|
+
|
|
232
|
+
await db.delete(docTable, { id: kept.id });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('update by encrypted search: WHERE title LIKE updates exactly the true matches', async () => {
|
|
236
|
+
const target = await db.insert(docTable, { scope: OWNER_A, title: 'quokka target row', name: null, kind: 'doc' });
|
|
237
|
+
const bystander = await db.insert(docTable, { scope: OWNER_A, title: 'quokka bystander', name: null, kind: 'doc' });
|
|
238
|
+
|
|
239
|
+
const updateCount = await db.update(docTable, { kind: 'flagged' }, titleLike('%target row%'));
|
|
240
|
+
expect(updateCount).toBe(1);
|
|
241
|
+
expect((await db.get(docTable, { id: target.id })).kind).toBe('flagged');
|
|
242
|
+
expect((await db.get(docTable, { id: bystander.id })).kind).toBe('doc');
|
|
243
|
+
|
|
244
|
+
await db.delete(docTable, { id: target.id });
|
|
245
|
+
await db.delete(docTable, { id: bystander.id });
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Db,
|
|
3
|
+
Record,
|
|
4
|
+
StringColumn,
|
|
5
|
+
Table,
|
|
6
|
+
withRecordColumns,
|
|
7
|
+
setDbEncryptionConfig,
|
|
8
|
+
InMemoryMasterKeyProvider,
|
|
9
|
+
DataKeyStore,
|
|
10
|
+
EncryptedColumns,
|
|
11
|
+
EncryptionEnvelope,
|
|
12
|
+
EncryptionLifecycleWalker,
|
|
13
|
+
QueryBuilder,
|
|
14
|
+
} from '@proteinjs/db';
|
|
15
|
+
import { SpannerDriver } from '@proteinjs/db-driver-spanner';
|
|
16
|
+
import { TransactionContext } from '@proteinjs/db-transaction-context';
|
|
17
|
+
import { getDropTestTable } from './util/getDropTestTable';
|
|
18
|
+
import { SpannerEmulatorProvisioner } from './util/SpannerEmulatorProvisioner';
|
|
19
|
+
import { EncWalkRow, EncWalkRowTable } from './util/columnEncryptionTestTables';
|
|
20
|
+
import { loadColumnEncryptionTestSchema, purgeColumnEncryptionTestRows } from './util/columnEncryptionTestHarness';
|
|
21
|
+
import { registerTestUser, clearTestUser } from '@proteinjs/db/test';
|
|
22
|
+
import '../generated/test/index';
|
|
23
|
+
|
|
24
|
+
const spannerDriver = new SpannerDriver({
|
|
25
|
+
projectId: 'proteinjs-test',
|
|
26
|
+
instanceName: 'proteinjs-test',
|
|
27
|
+
databaseName: 'test',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
(globalThis as any)['__proteinjs_db_defaultDbDriver'] = spannerDriver;
|
|
31
|
+
|
|
32
|
+
const OWNER = 'enc-walk-owner-a';
|
|
33
|
+
|
|
34
|
+
const walkTable = new EncWalkRowTable() as Table<EncWalkRow>;
|
|
35
|
+
const db = new Db(spannerDriver, undefined, new TransactionContext());
|
|
36
|
+
const systemDb = new Db(spannerDriver, undefined, new TransactionContext(), true);
|
|
37
|
+
const envelope = new EncryptionEnvelope();
|
|
38
|
+
const walker = new EncryptionLifecycleWalker();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The decrypt-out view of the walk table: the SAME physical table with the columns declared
|
|
42
|
+
* plaintext — how the schema looks after a deliberate reclassification flips the config.
|
|
43
|
+
* Deliberately NOT a registered loadable (the registry holds the encrypted declaration;
|
|
44
|
+
* name-based lookups only resolve column names/types, identical between the two).
|
|
45
|
+
*/
|
|
46
|
+
class EncWalkRowDecryptedView extends Table<EncWalkRow> {
|
|
47
|
+
name = 'db_test_enc_walk_row';
|
|
48
|
+
columns: Table<EncWalkRow>['columns'] = withRecordColumns<EncWalkRow>({
|
|
49
|
+
scope: new StringColumn('scope', {}, 36),
|
|
50
|
+
title: new StringColumn('title', { encrypted: false }, 'MAX'),
|
|
51
|
+
body: new StringColumn('body', { encrypted: false }, 'MAX'),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const rawRows = async (): Promise<any[]> =>
|
|
56
|
+
await spannerDriver.runQuery(() => ({
|
|
57
|
+
sql: `SELECT \`id\`, \`title\`, \`body\` FROM \`${walkTable.name}\` ORDER BY \`id\``,
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
const seedPlaintextRow = async (id: string, title: string, body: string) => {
|
|
61
|
+
await spannerDriver.runDml(() => ({
|
|
62
|
+
sql:
|
|
63
|
+
`INSERT INTO \`${walkTable.name}\` (\`id\`, \`created\`, \`updated\`, \`scope\`, \`title\`, \`body\`) ` +
|
|
64
|
+
`VALUES ('${id}', CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP(), '${OWNER}', '${title}', '${body}')`,
|
|
65
|
+
}));
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const titleLike = (pattern: string) =>
|
|
69
|
+
new QueryBuilder<EncWalkRow>(walkTable.name).condition({ field: 'title', operator: 'LIKE', value: pattern });
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The one reusable ONLINE backfill behind every config transition (encrypt-in-place
|
|
73
|
+
* adoption, add-searchable tokenization, decrypt-out, key rotation) — idempotent,
|
|
74
|
+
* resumable, migration-runner-shaped.
|
|
75
|
+
*/
|
|
76
|
+
describe('Encryption lifecycle walker', () => {
|
|
77
|
+
const dropTable = getDropTestTable(spannerDriver);
|
|
78
|
+
const tableManager = spannerDriver.getTableManager();
|
|
79
|
+
|
|
80
|
+
beforeAll(async () => {
|
|
81
|
+
await SpannerEmulatorProvisioner.ensureProvisioned({
|
|
82
|
+
projectId: 'proteinjs-test',
|
|
83
|
+
instanceName: 'proteinjs-test',
|
|
84
|
+
databaseName: 'test',
|
|
85
|
+
});
|
|
86
|
+
registerTestUser();
|
|
87
|
+
setDbEncryptionConfig({
|
|
88
|
+
masterKeyProvider: new InMemoryMasterKeyProvider('column-encryption-test'),
|
|
89
|
+
getAccessibleKeyOwners: async () => [OWNER],
|
|
90
|
+
});
|
|
91
|
+
await loadColumnEncryptionTestSchema(tableManager, [walkTable]);
|
|
92
|
+
await purgeColumnEncryptionTestRows(spannerDriver, [walkTable]);
|
|
93
|
+
}, 120000);
|
|
94
|
+
|
|
95
|
+
afterAll(async () => {
|
|
96
|
+
setDbEncryptionConfig(undefined);
|
|
97
|
+
clearTestUser();
|
|
98
|
+
await dropTable(new EncryptedColumns().tokenTableFor(walkTable)!);
|
|
99
|
+
await dropTable(walkTable);
|
|
100
|
+
await SpannerEmulatorProvisioner.release();
|
|
101
|
+
}, 120000);
|
|
102
|
+
|
|
103
|
+
afterEach(async () => {
|
|
104
|
+
await spannerDriver.runDml(() => ({ sql: `DELETE FROM \`${walkTable.name}\` WHERE TRUE` }));
|
|
105
|
+
const tokenTable = new EncryptedColumns().tokenTableFor(walkTable)!;
|
|
106
|
+
await spannerDriver.runDml(() => ({ sql: `DELETE FROM \`${tokenTable.name}\` WHERE TRUE` }));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('encrypt-in-place adoption: plaintext rows become envelopes, search works, live rows are skipped (idempotence)', async () => {
|
|
110
|
+
// the pre-encryption estate
|
|
111
|
+
await seedPlaintextRow('walk-adopt-1', 'meeting notes alpha', 'body one');
|
|
112
|
+
await seedPlaintextRow('walk-adopt-2', 'meeting notes beta', 'body two');
|
|
113
|
+
await seedPlaintextRow('walk-adopt-3', 'unrelated title', 'body three');
|
|
114
|
+
// a row already written through the live seam (already encrypted)
|
|
115
|
+
const liveRow = await db.insert(walkTable, { scope: OWNER, title: 'live row gamma', body: 'live body' });
|
|
116
|
+
|
|
117
|
+
const summary = await walker.walkTable(walkTable, 'encrypt', {
|
|
118
|
+
dbDriver: spannerDriver,
|
|
119
|
+
db: systemDb,
|
|
120
|
+
windowSize: 2, // exercise multi-window cursor paging
|
|
121
|
+
});
|
|
122
|
+
expect(summary.scanned).toBe(4);
|
|
123
|
+
expect(summary.rewritten).toBe(3); // the three plaintext rows; the live row skipped
|
|
124
|
+
|
|
125
|
+
// all rows now ciphertext at rest
|
|
126
|
+
for (const raw of await rawRows()) {
|
|
127
|
+
expect(envelope.isEnvelope(raw.title)).toBe(true);
|
|
128
|
+
expect(envelope.isEnvelope(raw.body)).toBe(true);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// the adopted rows are searchable (tokens were written by the rewrite)
|
|
132
|
+
const found = await db.query(walkTable, titleLike('%meeting notes%'));
|
|
133
|
+
expect(found.map((row) => row.title).sort()).toEqual(['meeting notes alpha', 'meeting notes beta']);
|
|
134
|
+
|
|
135
|
+
// and values still read back exactly
|
|
136
|
+
expect((await db.get(walkTable, { id: 'walk-adopt-1' })).body).toBe('body one');
|
|
137
|
+
|
|
138
|
+
// IDEMPOTENCE: a re-run rewrites nothing
|
|
139
|
+
const rerun = await walker.walkTable(walkTable, 'encrypt', { dbDriver: spannerDriver, db: systemDb });
|
|
140
|
+
expect(rerun.scanned).toBe(4);
|
|
141
|
+
expect(rerun.rewritten).toBe(0);
|
|
142
|
+
|
|
143
|
+
void liveRow;
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('resumability: a walk resumed from a cursor completes the remainder; done rows stay done', async () => {
|
|
147
|
+
await seedPlaintextRow('walk-resume-1', 'resume one', 'b');
|
|
148
|
+
await seedPlaintextRow('walk-resume-2', 'resume two', 'b');
|
|
149
|
+
await seedPlaintextRow('walk-resume-3', 'resume three', 'b');
|
|
150
|
+
|
|
151
|
+
// "crash" after the first row: simulate by walking only rows after a cursor — then
|
|
152
|
+
// verify a fresh full run converges the rest without redoing done work
|
|
153
|
+
const partial = await walker.walkTable(walkTable, 'encrypt', {
|
|
154
|
+
dbDriver: spannerDriver,
|
|
155
|
+
db: systemDb,
|
|
156
|
+
startAfterId: 'walk-resume-1',
|
|
157
|
+
});
|
|
158
|
+
expect(partial.rewritten).toBe(2);
|
|
159
|
+
|
|
160
|
+
const finish = await walker.walkTable(walkTable, 'encrypt', { dbDriver: spannerDriver, db: systemDb });
|
|
161
|
+
expect(finish.rewritten).toBe(1); // only the row the partial walk never reached
|
|
162
|
+
|
|
163
|
+
for (const raw of await rawRows()) {
|
|
164
|
+
expect(envelope.isEnvelope(raw.title)).toBe(true);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('decrypt-out reclassification: envelopes become plaintext, token rows are swept', async () => {
|
|
169
|
+
await db.insert(walkTable, { scope: OWNER, title: 'soon public alpha', body: 'body a' });
|
|
170
|
+
await db.insert(walkTable, { scope: OWNER, title: 'soon public beta', body: 'body b' });
|
|
171
|
+
|
|
172
|
+
const decryptedView = new EncWalkRowDecryptedView() as Table<EncWalkRow>;
|
|
173
|
+
const summary = await walker.walkTable(decryptedView, 'decrypt', {
|
|
174
|
+
dbDriver: spannerDriver,
|
|
175
|
+
db: systemDb,
|
|
176
|
+
columns: ['title', 'body'],
|
|
177
|
+
});
|
|
178
|
+
expect(summary.rewritten).toBe(2);
|
|
179
|
+
|
|
180
|
+
for (const raw of await rawRows()) {
|
|
181
|
+
expect(envelope.isEnvelope(raw.title)).toBe(false);
|
|
182
|
+
expect(raw.title).toContain('soon public');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const tokenTable = new EncryptedColumns().tokenTableFor(walkTable)!;
|
|
186
|
+
const tokenCount = await spannerDriver.runQuery(() => ({
|
|
187
|
+
sql: `SELECT COUNT(*) as tokenCount FROM \`${tokenTable.name}\``,
|
|
188
|
+
}));
|
|
189
|
+
expect(Number((tokenCount[0] as any).tokenCount)).toBe(0);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('key rotation: rotate mints a new version, the walk rewrites envelopes to it, search stays exact, old version retires', async () => {
|
|
193
|
+
const row1 = await db.insert(walkTable, { scope: OWNER, title: 'rotation subject one', body: 'rb1' });
|
|
194
|
+
const row2 = await db.insert(walkTable, { scope: OWNER, title: 'rotation subject two', body: 'rb2' });
|
|
195
|
+
|
|
196
|
+
const store = new DataKeyStore();
|
|
197
|
+
const versionBefore = envelope.parse((await rawRows())[0].title)!.version;
|
|
198
|
+
const newVersion = await store.rotateKey(OWNER);
|
|
199
|
+
expect(newVersion).toBe(versionBefore + 1);
|
|
200
|
+
|
|
201
|
+
// during the rotation window (before the walk), reads and search still work
|
|
202
|
+
expect((await db.get(walkTable, { id: row1.id })).title).toBe('rotation subject one');
|
|
203
|
+
expect((await db.query(walkTable, titleLike('%rotation subject%'))).length).toBe(2);
|
|
204
|
+
|
|
205
|
+
const summary = await walker.walkTable(walkTable, 'rotate-keys', { dbDriver: spannerDriver, db: systemDb });
|
|
206
|
+
expect(summary.rewritten).toBe(2);
|
|
207
|
+
|
|
208
|
+
for (const raw of await rawRows()) {
|
|
209
|
+
expect(envelope.parse(raw.title)!.version).toBe(newVersion);
|
|
210
|
+
expect(envelope.parse(raw.body)!.version).toBe(newVersion);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// idempotence: nothing left at the old version
|
|
214
|
+
const rerun = await walker.walkTable(walkTable, 'rotate-keys', { dbDriver: spannerDriver, db: systemDb });
|
|
215
|
+
expect(rerun.rewritten).toBe(0);
|
|
216
|
+
|
|
217
|
+
// retire the old version: reads and search remain exact on the new key
|
|
218
|
+
await store.retireKeyVersion(OWNER, versionBefore);
|
|
219
|
+
expect((await db.get(walkTable, { id: row2.id })).title).toBe('rotation subject two');
|
|
220
|
+
const found = await db.query(walkTable, titleLike('%rotation subject%'));
|
|
221
|
+
expect(found.length).toBe(2);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Db,
|
|
3
|
+
Table,
|
|
4
|
+
setDbEncryptionConfig,
|
|
5
|
+
InMemoryMasterKeyProvider,
|
|
6
|
+
DataKeyStore,
|
|
7
|
+
EncryptionEnvelope,
|
|
8
|
+
QueryBuilder,
|
|
9
|
+
} from '@proteinjs/db';
|
|
10
|
+
import { SpannerDriver } from '@proteinjs/db-driver-spanner';
|
|
11
|
+
import { TransactionContext } from '@proteinjs/db-transaction-context';
|
|
12
|
+
import { getDropTestTable } from './util/getDropTestTable';
|
|
13
|
+
import { SpannerEmulatorProvisioner } from './util/SpannerEmulatorProvisioner';
|
|
14
|
+
import { EncPerfRow, EncPerfRowTable, PlainPerfRowTable } from './util/columnEncryptionTestTables';
|
|
15
|
+
import { loadColumnEncryptionTestSchema, purgeColumnEncryptionTestRows } from './util/columnEncryptionTestHarness';
|
|
16
|
+
import { registerTestUser, clearTestUser } from '@proteinjs/db/test';
|
|
17
|
+
import '../generated/test/index';
|
|
18
|
+
|
|
19
|
+
const spannerDriver = new SpannerDriver({
|
|
20
|
+
projectId: 'proteinjs-test',
|
|
21
|
+
instanceName: 'proteinjs-test',
|
|
22
|
+
databaseName: 'test',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
(globalThis as any)['__proteinjs_db_defaultDbDriver'] = spannerDriver;
|
|
26
|
+
|
|
27
|
+
const OWNER = 'enc-perf-owner-a';
|
|
28
|
+
const ROW_COUNT = 1000;
|
|
29
|
+
|
|
30
|
+
const encTable = new EncPerfRowTable() as Table<EncPerfRow>;
|
|
31
|
+
const plainTable = new PlainPerfRowTable() as Table<EncPerfRow>;
|
|
32
|
+
const db = new Db(spannerDriver, undefined, new TransactionContext());
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Performance sanity for the doc's claims (TRUST_AND_COMPLIANCE §1.5): per-value AES-256-GCM
|
|
36
|
+
* decrypt ~1-3µs in Node; a 1k-row read's decrypt overhead an order of magnitude below the
|
|
37
|
+
* database/network latency the read already pays. Numbers are printed for the report.
|
|
38
|
+
*/
|
|
39
|
+
describe('Column encryption performance sanity', () => {
|
|
40
|
+
const dropTable = getDropTestTable(spannerDriver);
|
|
41
|
+
const tableManager = spannerDriver.getTableManager();
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
await SpannerEmulatorProvisioner.ensureProvisioned({
|
|
45
|
+
projectId: 'proteinjs-test',
|
|
46
|
+
instanceName: 'proteinjs-test',
|
|
47
|
+
databaseName: 'test',
|
|
48
|
+
});
|
|
49
|
+
registerTestUser();
|
|
50
|
+
setDbEncryptionConfig({
|
|
51
|
+
masterKeyProvider: new InMemoryMasterKeyProvider('column-encryption-test'),
|
|
52
|
+
getAccessibleKeyOwners: async () => [OWNER],
|
|
53
|
+
});
|
|
54
|
+
await loadColumnEncryptionTestSchema(tableManager, [encTable, plainTable]);
|
|
55
|
+
await purgeColumnEncryptionTestRows(spannerDriver, [encTable, plainTable]);
|
|
56
|
+
|
|
57
|
+
// Seed 1k rows in each table with identical ~120-char bodies (batched inserts via txns
|
|
58
|
+
// to keep seeding fast on the emulator).
|
|
59
|
+
const body = (index: number) =>
|
|
60
|
+
`note body ${index} — a sentence of ordinary length, the kind a document row actually stores in practice.`;
|
|
61
|
+
for (let batch = 0; batch < ROW_COUNT / 100; batch++) {
|
|
62
|
+
await db.runTransaction(async () => {
|
|
63
|
+
for (let i = 0; i < 100; i++) {
|
|
64
|
+
const index = batch * 100 + i;
|
|
65
|
+
await db.insert(encTable, { scope: OWNER, body: body(index) });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
await db.runTransaction(async () => {
|
|
69
|
+
for (let i = 0; i < 100; i++) {
|
|
70
|
+
const index = batch * 100 + i;
|
|
71
|
+
await db.insert(plainTable, { scope: OWNER, body: body(index) });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}, 600000);
|
|
76
|
+
|
|
77
|
+
afterAll(async () => {
|
|
78
|
+
setDbEncryptionConfig(undefined);
|
|
79
|
+
clearTestUser();
|
|
80
|
+
await dropTable(encTable);
|
|
81
|
+
await dropTable(plainTable);
|
|
82
|
+
await SpannerEmulatorProvisioner.release();
|
|
83
|
+
}, 120000);
|
|
84
|
+
|
|
85
|
+
const timeQuery = async (table: Table<EncPerfRow>): Promise<{ ms: number; rows: number }> => {
|
|
86
|
+
const start = process.hrtime.bigint();
|
|
87
|
+
const rows = await db.query(
|
|
88
|
+
table,
|
|
89
|
+
new QueryBuilder<EncPerfRow>(table.name).condition({ field: 'scope', operator: '=', value: OWNER })
|
|
90
|
+
);
|
|
91
|
+
const ms = Number(process.hrtime.bigint() - start) / 1e6;
|
|
92
|
+
return { ms, rows: rows.length };
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
test('1k-row read: decrypt overhead measured against the plaintext twin', async () => {
|
|
96
|
+
// warm the key cache + driver sessions
|
|
97
|
+
await timeQuery(encTable);
|
|
98
|
+
await timeQuery(plainTable);
|
|
99
|
+
|
|
100
|
+
const samples = 5;
|
|
101
|
+
let encTotal = 0;
|
|
102
|
+
let plainTotal = 0;
|
|
103
|
+
for (let i = 0; i < samples; i++) {
|
|
104
|
+
encTotal += (await timeQuery(encTable)).ms;
|
|
105
|
+
plainTotal += (await timeQuery(plainTable)).ms;
|
|
106
|
+
}
|
|
107
|
+
const encAvg = encTotal / samples;
|
|
108
|
+
const plainAvg = plainTotal / samples;
|
|
109
|
+
const overheadMs = encAvg - plainAvg;
|
|
110
|
+
const perValueUs = (overheadMs * 1000) / ROW_COUNT;
|
|
111
|
+
|
|
112
|
+
// eslint-disable-next-line no-console
|
|
113
|
+
console.info(
|
|
114
|
+
`[perf] 1k-row read: encrypted ${encAvg.toFixed(1)}ms vs plaintext ${plainAvg.toFixed(1)}ms — ` +
|
|
115
|
+
`decrypt overhead ${overheadMs.toFixed(1)}ms total, ~${perValueUs.toFixed(2)}µs/value (doc claim: ~1-3µs/value)`
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const encRows = await db.query(
|
|
119
|
+
encTable,
|
|
120
|
+
new QueryBuilder<EncPerfRow>(encTable.name).condition({ field: 'scope', operator: '=', value: OWNER })
|
|
121
|
+
);
|
|
122
|
+
expect(encRows.length).toBe(ROW_COUNT);
|
|
123
|
+
// Sanity bound, generous (emulator variance): the whole-read overhead stays well under
|
|
124
|
+
// the read itself — decrypt must not dominate.
|
|
125
|
+
expect(overheadMs).toBeLessThan(Math.max(plainAvg, 250));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('per-value decrypt microbenchmark: the in-process AES-256-GCM cost itself', async () => {
|
|
129
|
+
const key = await new DataKeyStore().getWriteKey(OWNER);
|
|
130
|
+
const envelope = new EncryptionEnvelope();
|
|
131
|
+
const value = 'note body — a sentence of ordinary length, the kind a document row actually stores in practice.';
|
|
132
|
+
const encrypted = envelope.encrypt(value, key);
|
|
133
|
+
|
|
134
|
+
const iterations = 10000;
|
|
135
|
+
// warm
|
|
136
|
+
for (let i = 0; i < 1000; i++) {
|
|
137
|
+
envelope.decrypt(encrypted, key);
|
|
138
|
+
}
|
|
139
|
+
const start = process.hrtime.bigint();
|
|
140
|
+
for (let i = 0; i < iterations; i++) {
|
|
141
|
+
envelope.decrypt(encrypted, key);
|
|
142
|
+
}
|
|
143
|
+
const totalUs = Number(process.hrtime.bigint() - start) / 1e3;
|
|
144
|
+
const perValueUs = totalUs / iterations;
|
|
145
|
+
|
|
146
|
+
// eslint-disable-next-line no-console
|
|
147
|
+
console.info(
|
|
148
|
+
`[perf] in-process decrypt: ${perValueUs.toFixed(2)}µs/value over ${iterations} iterations ` +
|
|
149
|
+
`(doc claim: ~1-3µs/value)`
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
expect(envelope.decrypt(encrypted, key)).toBe(value);
|
|
153
|
+
// The claim's order of magnitude, with generous headroom for slow machines
|
|
154
|
+
expect(perValueUs).toBeLessThan(30);
|
|
155
|
+
});
|
|
156
|
+
});
|