@proteinjs/db-driver-spanner 1.23.4 → 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 +27 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- 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/SpannerDriver.d.ts +13 -0
- package/dist/src/SpannerDriver.d.ts.map +1 -1
- package/dist/src/SpannerDriver.js +38 -2
- package/dist/src/SpannerDriver.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/Float64Params.test.d.ts +2 -0
- package/dist/test/Float64Params.test.d.ts.map +1 -0
- package/dist/test/Float64Params.test.js +242 -0
- package/dist/test/Float64Params.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/index.ts +1 -1
- package/generated/test/index.ts +15 -1
- package/package.json +6 -6
- package/src/SpannerDriver.ts +38 -2
- 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/Float64Params.test.ts +131 -0
- package/test/index.ts +1 -0
- package/test/util/columnEncryptionTestHarness.ts +54 -0
- package/test/util/columnEncryptionTestTables.ts +115 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Db,
|
|
3
|
+
Table,
|
|
4
|
+
setDbEncryptionConfig,
|
|
5
|
+
InMemoryMasterKeyProvider,
|
|
6
|
+
EncryptedColumns,
|
|
7
|
+
EncryptedColumnQueryError,
|
|
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 { EncSortedItem, EncSortedItemTable, EncUniqueTag, EncUniqueTagTable } 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_A = 'enc-sort-owner-a';
|
|
28
|
+
const OWNER_B = 'enc-sort-owner-b';
|
|
29
|
+
|
|
30
|
+
const sortedTable = new EncSortedItemTable() as Table<EncSortedItem>;
|
|
31
|
+
const uniqueTable = new EncUniqueTagTable() as Table<EncUniqueTag>;
|
|
32
|
+
const db = new Db(spannerDriver, undefined, new TransactionContext());
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The sortKey tier of the sorting story (a DECLARED bounded reveal → native ORDER BY at any
|
|
36
|
+
* scale), the loud live rejection without one, and value uniqueness through the equality
|
|
37
|
+
* fingerprint (per owner).
|
|
38
|
+
*/
|
|
39
|
+
describe('Encrypted sortKey ORDER BY + fingerprint uniqueness', () => {
|
|
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_A],
|
|
53
|
+
});
|
|
54
|
+
await loadColumnEncryptionTestSchema(tableManager, [sortedTable, uniqueTable]);
|
|
55
|
+
await purgeColumnEncryptionTestRows(spannerDriver, [sortedTable, uniqueTable]);
|
|
56
|
+
}, 120000);
|
|
57
|
+
|
|
58
|
+
afterAll(async () => {
|
|
59
|
+
setDbEncryptionConfig(undefined);
|
|
60
|
+
clearTestUser();
|
|
61
|
+
await dropTable(new EncryptedColumns().tokenTableFor(sortedTable)!);
|
|
62
|
+
await dropTable(sortedTable);
|
|
63
|
+
await dropTable(uniqueTable);
|
|
64
|
+
await SpannerEmulatorProvisioner.release();
|
|
65
|
+
}, 120000);
|
|
66
|
+
|
|
67
|
+
test('sortKey: native ORDER BY + sorted pagination over encrypted values; only the declared prefix is revealed at rest', async () => {
|
|
68
|
+
const titles = ['banana bread', 'Apple pie', 'cherry tart', 'apricot jam', 'Blueberry scone'];
|
|
69
|
+
const ids: string[] = [];
|
|
70
|
+
for (const title of titles) {
|
|
71
|
+
ids.push((await db.insert(sortedTable, { scope: OWNER_A, title })).id);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// native DB-side ORDER BY on the encrypted column (revealPrefix=3 buckets), ascending
|
|
75
|
+
const sorted = await db.query(
|
|
76
|
+
sortedTable,
|
|
77
|
+
new QueryBuilder<EncSortedItem>(sortedTable.name)
|
|
78
|
+
.condition({ field: 'scope', operator: '=', value: OWNER_A })
|
|
79
|
+
.sort([{ field: 'title' }])
|
|
80
|
+
);
|
|
81
|
+
const sortedTitles = sorted.map((row) => row.title);
|
|
82
|
+
// prefix buckets (first 3 chars, normalized): app=Apple/apricot tie-break within bucket,
|
|
83
|
+
// then ban, blu, che — assert bucket ORDER, the declared guarantee
|
|
84
|
+
const buckets = sortedTitles.map((title) => (title ?? '').toLowerCase().slice(0, 3));
|
|
85
|
+
expect(buckets).toEqual([...buckets].sort());
|
|
86
|
+
|
|
87
|
+
// sorted PAGINATION: the first page of the prefix order
|
|
88
|
+
const firstPage = await db.query(
|
|
89
|
+
sortedTable,
|
|
90
|
+
new QueryBuilder<EncSortedItem>(sortedTable.name)
|
|
91
|
+
.condition({ field: 'scope', operator: '=', value: OWNER_A })
|
|
92
|
+
.sort([{ field: 'title' }])
|
|
93
|
+
.paginate({ start: 0, end: 2 })
|
|
94
|
+
);
|
|
95
|
+
expect(firstPage.length).toBe(2);
|
|
96
|
+
for (const row of firstPage) {
|
|
97
|
+
expect((row.title ?? '').toLowerCase().slice(0, 2)).toBe('ap');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// at rest: the sort companion holds EXACTLY the declared 3-character reveal
|
|
101
|
+
const raw = await spannerDriver.runQuery(() => ({
|
|
102
|
+
sql: `SELECT \`title\`, \`title_enc_srt\` FROM \`${sortedTable.name}\` WHERE \`id\` = '${ids[0]}'`,
|
|
103
|
+
}));
|
|
104
|
+
expect((raw[0] as any).title_enc_srt).toBe('ban');
|
|
105
|
+
expect((raw[0] as any).title).not.toContain('banana');
|
|
106
|
+
|
|
107
|
+
for (const id of ids) {
|
|
108
|
+
await db.delete(sortedTable, { id });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('the live loud rejection: ORDER BY an encrypted column with no sortKey throws at query-build time', async () => {
|
|
113
|
+
const tagQb = new QueryBuilder<EncUniqueTag>(uniqueTable.name).sort([{ field: 'name' }]);
|
|
114
|
+
await expect(db.query(uniqueTable, tagQb)).rejects.toThrow(EncryptedColumnQueryError);
|
|
115
|
+
|
|
116
|
+
const tagQb2 = new QueryBuilder<EncUniqueTag>(uniqueTable.name).sort([{ field: 'name' }]);
|
|
117
|
+
await expect(db.query(uniqueTable, tagQb2)).rejects.toThrow(/no sortKey declared/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('uniqueness on the value rides the fingerprint: duplicate value for the same owner rejected; other owners unaffected', async () => {
|
|
121
|
+
const first = await db.insert(uniqueTable, { scope: OWNER_A, name: 'oncologist' });
|
|
122
|
+
await expect(db.insert(uniqueTable, { scope: OWNER_A, name: 'oncologist' })).rejects.toThrow();
|
|
123
|
+
|
|
124
|
+
// per-owner semantics: another owner may hold the same value (fingerprints are keyed per owner)
|
|
125
|
+
const otherOwner = await db.insert(uniqueTable, { scope: OWNER_B, name: 'oncologist' });
|
|
126
|
+
|
|
127
|
+
await db.delete(uniqueTable, { id: first.id });
|
|
128
|
+
await db.delete(uniqueTable, { id: otherOwner.id });
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { SpannerDriver } from '@proteinjs/db-driver-spanner';
|
|
2
|
+
import { FloatColumn, Record, StatementFactory, Table, tableByName, withRecordColumns } from '@proteinjs/db';
|
|
3
|
+
import { QueryBuilder } from '@proteinjs/db-query';
|
|
4
|
+
import { getDropTestTable } from './util/getDropTestTable';
|
|
5
|
+
import { SpannerEmulatorProvisioner } from './util/SpannerEmulatorProvisioner';
|
|
6
|
+
import '../generated/test/index';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* FLOAT64 param encoding through the driver's normal statement path (2026-08-24 sandbox cost
|
|
10
|
+
* telemetry defect: every provision's `compute_seconds=0` insert failed with "Could not parse 0
|
|
11
|
+
* as a FLOAT64"). The client codec stringifies integral JS numbers into the INT64 wire encoding
|
|
12
|
+
* regardless of the declared param type, so `0` and `7` were rejected while `0.5` passed — the
|
|
13
|
+
* driver must bind FLOAT64 params from the COLUMN type, never the value's integralness.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
interface Float64ParamsRecord extends Record {
|
|
17
|
+
computeSeconds: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class Float64ParamsTable extends Table<Float64ParamsRecord> {
|
|
21
|
+
name = 'db_test_float64_params';
|
|
22
|
+
columns = withRecordColumns<Float64ParamsRecord>({
|
|
23
|
+
computeSeconds: new FloatColumn('computeSeconds'),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const float64ParamsTable = new Float64ParamsTable();
|
|
28
|
+
|
|
29
|
+
const spannerDriver = new SpannerDriver(
|
|
30
|
+
{
|
|
31
|
+
projectId: 'proteinjs-test',
|
|
32
|
+
instanceName: 'proteinjs-test',
|
|
33
|
+
databaseName: 'test',
|
|
34
|
+
},
|
|
35
|
+
(name) => (name === float64ParamsTable.name ? float64ParamsTable : tableByName(name))
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
/** Per-run unique row ids — fixtures never collide across runs or with leftover rows. */
|
|
39
|
+
const uniqueId = () => `f64-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
40
|
+
|
|
41
|
+
/** The production insert shape (Db.insert): StatementFactory against the driver's own config. */
|
|
42
|
+
const insertRow = async (computeSeconds: number): Promise<string> => {
|
|
43
|
+
const id = uniqueId();
|
|
44
|
+
await spannerDriver.runDml((config) =>
|
|
45
|
+
new StatementFactory<Float64ParamsRecord>().insert(
|
|
46
|
+
float64ParamsTable.name,
|
|
47
|
+
{ id, computeSeconds } as Partial<Float64ParamsRecord>,
|
|
48
|
+
config
|
|
49
|
+
)
|
|
50
|
+
);
|
|
51
|
+
return id;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const selectRow = async (id: string): Promise<Float64ParamsRecord | undefined> => {
|
|
55
|
+
const qb = new QueryBuilder<Float64ParamsRecord>(float64ParamsTable.name).condition({
|
|
56
|
+
field: 'id',
|
|
57
|
+
operator: '=',
|
|
58
|
+
value: id,
|
|
59
|
+
});
|
|
60
|
+
const rows = await spannerDriver.runQuery((config) => qb.toSql(config));
|
|
61
|
+
return rows[0];
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
describe('Spanner FLOAT64 param encoding', () => {
|
|
65
|
+
const dropTable = getDropTestTable(spannerDriver);
|
|
66
|
+
|
|
67
|
+
beforeAll(async () => {
|
|
68
|
+
await SpannerEmulatorProvisioner.ensureProvisioned({
|
|
69
|
+
projectId: 'proteinjs-test',
|
|
70
|
+
instanceName: 'proteinjs-test',
|
|
71
|
+
databaseName: 'test',
|
|
72
|
+
});
|
|
73
|
+
await dropTable(float64ParamsTable);
|
|
74
|
+
await spannerDriver.getTableManager().loadTable(float64ParamsTable);
|
|
75
|
+
}, 60000);
|
|
76
|
+
|
|
77
|
+
afterAll(async () => {
|
|
78
|
+
await dropTable(float64ParamsTable);
|
|
79
|
+
await SpannerEmulatorProvisioner.release();
|
|
80
|
+
}, 60000);
|
|
81
|
+
|
|
82
|
+
test('writes 0 into a FLOAT64 column (the telemetry failure shape)', async () => {
|
|
83
|
+
const id = await insertRow(0);
|
|
84
|
+
const row = await selectRow(id);
|
|
85
|
+
expect(row?.computeSeconds).toBe(0);
|
|
86
|
+
}, 60000);
|
|
87
|
+
|
|
88
|
+
test('writes an integral non-zero value (7) into a FLOAT64 column', async () => {
|
|
89
|
+
const id = await insertRow(7);
|
|
90
|
+
const row = await selectRow(id);
|
|
91
|
+
expect(row?.computeSeconds).toBe(7);
|
|
92
|
+
}, 60000);
|
|
93
|
+
|
|
94
|
+
test('writes a fractional value (0.5) into a FLOAT64 column (control)', async () => {
|
|
95
|
+
const id = await insertRow(0.5);
|
|
96
|
+
const row = await selectRow(id);
|
|
97
|
+
expect(row?.computeSeconds).toBe(0.5);
|
|
98
|
+
}, 60000);
|
|
99
|
+
|
|
100
|
+
test('queries by an integral FLOAT64 condition value through the normal query path', async () => {
|
|
101
|
+
const marker = 12345;
|
|
102
|
+
const id = await insertRow(marker);
|
|
103
|
+
const qb = new QueryBuilder<Float64ParamsRecord>(float64ParamsTable.name).condition({
|
|
104
|
+
field: 'computeSeconds',
|
|
105
|
+
operator: '=',
|
|
106
|
+
value: marker,
|
|
107
|
+
});
|
|
108
|
+
const rows = await spannerDriver.runQuery((config) => qb.toSql(config));
|
|
109
|
+
expect(rows.map((row) => row.id)).toContain(id);
|
|
110
|
+
}, 60000);
|
|
111
|
+
|
|
112
|
+
test('updates a FLOAT64 column to an integral value through the normal update path', async () => {
|
|
113
|
+
const id = await insertRow(0.5);
|
|
114
|
+
const qb = new QueryBuilder<Float64ParamsRecord>(float64ParamsTable.name).condition({
|
|
115
|
+
field: 'id',
|
|
116
|
+
operator: '=',
|
|
117
|
+
value: id,
|
|
118
|
+
});
|
|
119
|
+
const updatedCount = await spannerDriver.runDml((config) =>
|
|
120
|
+
new StatementFactory<Float64ParamsRecord>().update(
|
|
121
|
+
float64ParamsTable.name,
|
|
122
|
+
{ computeSeconds: 0 } as Partial<Float64ParamsRecord>,
|
|
123
|
+
qb,
|
|
124
|
+
config
|
|
125
|
+
)
|
|
126
|
+
);
|
|
127
|
+
expect(updatedCount).toBe(1);
|
|
128
|
+
const row = await selectRow(id);
|
|
129
|
+
expect(row?.computeSeconds).toBe(0);
|
|
130
|
+
}, 60000);
|
|
131
|
+
});
|
package/test/index.ts
CHANGED