@proteinjs/db 1.35.1 → 1.36.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 +11 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/generated/index.js +7 -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 +7 -1
- package/dist/generated/test/index.js.map +1 -1
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/src/Db.d.ts +25 -0
- package/dist/src/Db.d.ts.map +1 -1
- package/dist/src/Db.js +219 -22
- package/dist/src/Db.js.map +1 -1
- package/dist/src/Record.d.ts +7 -1
- package/dist/src/Record.d.ts.map +1 -1
- package/dist/src/Record.js +56 -21
- package/dist/src/Record.js.map +1 -1
- package/dist/src/Table.d.ts +82 -1
- package/dist/src/Table.d.ts.map +1 -1
- package/dist/src/Table.js +24 -1
- package/dist/src/Table.js.map +1 -1
- package/dist/src/TableQueryTransformProvider.d.ts +17 -0
- package/dist/src/TableQueryTransformProvider.d.ts.map +1 -0
- package/dist/src/TableQueryTransformProvider.js +34 -0
- package/dist/src/TableQueryTransformProvider.js.map +1 -0
- package/dist/src/encryption/Base64Url.d.ts +12 -0
- package/dist/src/encryption/Base64Url.d.ts.map +1 -0
- package/dist/src/encryption/Base64Url.js +21 -0
- package/dist/src/encryption/Base64Url.js.map +1 -0
- package/dist/src/encryption/DataEncryptionKeyTable.d.ts +33 -0
- package/dist/src/encryption/DataEncryptionKeyTable.d.ts.map +1 -0
- package/dist/src/encryption/DataEncryptionKeyTable.js +45 -0
- package/dist/src/encryption/DataEncryptionKeyTable.js.map +1 -0
- package/dist/src/encryption/DataKeyStore.d.ts +84 -0
- package/dist/src/encryption/DataKeyStore.d.ts.map +1 -0
- package/dist/src/encryption/DataKeyStore.js +481 -0
- package/dist/src/encryption/DataKeyStore.js.map +1 -0
- package/dist/src/encryption/DbEncryptionConfig.d.ts +62 -0
- package/dist/src/encryption/DbEncryptionConfig.d.ts.map +1 -0
- package/dist/src/encryption/DbEncryptionConfig.js +67 -0
- package/dist/src/encryption/DbEncryptionConfig.js.map +1 -0
- package/dist/src/encryption/EncryptedColumnQueryError.d.ts +10 -0
- package/dist/src/encryption/EncryptedColumnQueryError.d.ts.map +1 -0
- package/dist/src/encryption/EncryptedColumnQueryError.js +38 -0
- package/dist/src/encryption/EncryptedColumnQueryError.js.map +1 -0
- package/dist/src/encryption/EncryptedColumnQueryTransform.d.ts +52 -0
- package/dist/src/encryption/EncryptedColumnQueryTransform.d.ts.map +1 -0
- package/dist/src/encryption/EncryptedColumnQueryTransform.js +423 -0
- package/dist/src/encryption/EncryptedColumnQueryTransform.js.map +1 -0
- package/dist/src/encryption/EncryptedColumns.d.ts +69 -0
- package/dist/src/encryption/EncryptedColumns.d.ts.map +1 -0
- package/dist/src/encryption/EncryptedColumns.js +286 -0
- package/dist/src/encryption/EncryptedColumns.js.map +1 -0
- package/dist/src/encryption/EncryptionDerivedTableRegistry.d.ts +20 -0
- package/dist/src/encryption/EncryptionDerivedTableRegistry.d.ts.map +1 -0
- package/dist/src/encryption/EncryptionDerivedTableRegistry.js +36 -0
- package/dist/src/encryption/EncryptionDerivedTableRegistry.js.map +1 -0
- package/dist/src/encryption/EncryptionEnvelope.d.ts +25 -0
- package/dist/src/encryption/EncryptionEnvelope.d.ts.map +1 -0
- package/dist/src/encryption/EncryptionEnvelope.js +59 -0
- package/dist/src/encryption/EncryptionEnvelope.js.map +1 -0
- package/dist/src/encryption/EncryptionLifecycleWalker.d.ts +65 -0
- package/dist/src/encryption/EncryptionLifecycleWalker.d.ts.map +1 -0
- package/dist/src/encryption/EncryptionLifecycleWalker.js +343 -0
- package/dist/src/encryption/EncryptionLifecycleWalker.js.map +1 -0
- package/dist/src/encryption/EncryptionRecordHooks.d.ts +47 -0
- package/dist/src/encryption/EncryptionRecordHooks.d.ts.map +1 -0
- package/dist/src/encryption/EncryptionRecordHooks.js +229 -0
- package/dist/src/encryption/EncryptionRecordHooks.js.map +1 -0
- package/dist/src/encryption/EncryptionTokenMaintenance.d.ts +34 -0
- package/dist/src/encryption/EncryptionTokenMaintenance.d.ts.map +1 -0
- package/dist/src/encryption/EncryptionTokenMaintenance.js +211 -0
- package/dist/src/encryption/EncryptionTokenMaintenance.js.map +1 -0
- package/dist/src/encryption/InMemoryMasterKeyProvider.d.ts +20 -0
- package/dist/src/encryption/InMemoryMasterKeyProvider.d.ts.map +1 -0
- package/dist/src/encryption/InMemoryMasterKeyProvider.js +87 -0
- package/dist/src/encryption/InMemoryMasterKeyProvider.js.map +1 -0
- package/dist/src/encryption/MasterKeyProvider.d.ts +27 -0
- package/dist/src/encryption/MasterKeyProvider.d.ts.map +1 -0
- package/dist/src/encryption/MasterKeyProvider.js +3 -0
- package/dist/src/encryption/MasterKeyProvider.js.map +1 -0
- package/dist/src/encryption/SearchTokenizer.d.ts +49 -0
- package/dist/src/encryption/SearchTokenizer.d.ts.map +1 -0
- package/dist/src/encryption/SearchTokenizer.js +106 -0
- package/dist/src/encryption/SearchTokenizer.js.map +1 -0
- package/dist/src/schema/TableManager.d.ts +10 -0
- package/dist/src/schema/TableManager.d.ts.map +1 -1
- package/dist/src/schema/TableManager.js +62 -5
- package/dist/src/schema/TableManager.js.map +1 -1
- package/dist/src/tables/MigrationTable.d.ts +2 -2
- package/dist/test/EncryptedColumnsSchema.test.d.ts +2 -0
- package/dist/test/EncryptedColumnsSchema.test.d.ts.map +1 -0
- package/dist/test/EncryptedColumnsSchema.test.js +151 -0
- package/dist/test/EncryptedColumnsSchema.test.js.map +1 -0
- package/dist/test/EncryptionEnvelope.test.d.ts +2 -0
- package/dist/test/EncryptionEnvelope.test.d.ts.map +1 -0
- package/dist/test/EncryptionEnvelope.test.js +109 -0
- package/dist/test/EncryptionEnvelope.test.js.map +1 -0
- package/dist/test/EncryptionQueryContract.test.d.ts +2 -0
- package/dist/test/EncryptionQueryContract.test.d.ts.map +1 -0
- package/dist/test/EncryptionQueryContract.test.js +399 -0
- package/dist/test/EncryptionQueryContract.test.js.map +1 -0
- package/dist/test/SearchTokenizer.test.d.ts +2 -0
- package/dist/test/SearchTokenizer.test.d.ts.map +1 -0
- package/dist/test/SearchTokenizer.test.js +64 -0
- package/dist/test/SearchTokenizer.test.js.map +1 -0
- package/dist/test/reusable/CascadeDeleteTests.js +2 -2
- package/dist/test/reusable/CascadeDeleteTests.js.map +1 -1
- package/dist/test/reusable/ColumnTypesTests.js +2 -2
- package/dist/test/reusable/ColumnTypesTests.js.map +1 -1
- package/dist/test/reusable/CrudTests.js +2 -2
- package/dist/test/reusable/CrudTests.js.map +1 -1
- package/dist/test/reusable/DynamicReferenceColumnTests.js +2 -2
- package/dist/test/reusable/DynamicReferenceColumnTests.js.map +1 -1
- package/dist/test/reusable/PreloadReferencesTests.js +2 -2
- package/dist/test/reusable/PreloadReferencesTests.js.map +1 -1
- package/dist/test/reusable/RecordIteratorTests.js +2 -2
- package/dist/test/reusable/RecordIteratorTests.js.map +1 -1
- package/dist/test/reusable/TableManagerTests.js +2 -2
- package/dist/test/reusable/TableManagerTests.js.map +1 -1
- package/dist/test/reusable/TransactionTests.js +2 -2
- package/dist/test/reusable/TransactionTests.js.map +1 -1
- package/generated/index.ts +7 -1
- package/generated/test/index.ts +7 -1
- package/index.ts +16 -0
- package/package.json +3 -3
- package/src/Db.ts +137 -12
- package/src/Record.ts +21 -4
- package/src/Table.ts +109 -2
- package/src/TableQueryTransformProvider.ts +31 -0
- package/src/encryption/Base64Url.ts +14 -0
- package/src/encryption/DataEncryptionKeyTable.ts +40 -0
- package/src/encryption/DataKeyStore.ts +305 -0
- package/src/encryption/DbEncryptionConfig.ts +92 -0
- package/src/encryption/EncryptedColumnQueryError.ts +15 -0
- package/src/encryption/EncryptedColumnQueryTransform.ts +383 -0
- package/src/encryption/EncryptedColumns.ts +318 -0
- package/src/encryption/EncryptionDerivedTableRegistry.ts +34 -0
- package/src/encryption/EncryptionEnvelope.ts +60 -0
- package/src/encryption/EncryptionLifecycleWalker.ts +243 -0
- package/src/encryption/EncryptionRecordHooks.ts +167 -0
- package/src/encryption/EncryptionTokenMaintenance.ts +123 -0
- package/src/encryption/InMemoryMasterKeyProvider.ts +42 -0
- package/src/encryption/MasterKeyProvider.ts +26 -0
- package/src/encryption/SearchTokenizer.ts +103 -0
- package/src/schema/TableManager.ts +43 -1
- package/test/EncryptedColumnsSchema.test.ts +172 -0
- package/test/EncryptionEnvelope.test.ts +66 -0
- package/test/EncryptionQueryContract.test.ts +195 -0
- package/test/SearchTokenizer.test.ts +67 -0
- package/test/reusable/CascadeDeleteTests.ts +2 -2
- package/test/reusable/ColumnTypesTests.ts +2 -2
- package/test/reusable/CrudTests.ts +2 -2
- package/test/reusable/DynamicReferenceColumnTests.ts +2 -2
- package/test/reusable/PreloadReferencesTests.ts +2 -2
- package/test/reusable/RecordIteratorTests.ts +2 -2
- package/test/reusable/TableManagerTests.ts +2 -2
- package/test/reusable/TransactionTests.ts +2 -2
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { Table } from '../Table';
|
|
2
|
+
import type { SerializedRecord } from '../Record';
|
|
3
|
+
import { EncryptedColumnConfigError, getDbEncryptionConfig } from './DbEncryptionConfig';
|
|
4
|
+
import { EncryptedColumns } from './EncryptedColumns';
|
|
5
|
+
import { EncryptionEnvelope } from './EncryptionEnvelope';
|
|
6
|
+
import { DataKeyMaterial, DataKeyStore } from './DataKeyStore';
|
|
7
|
+
import { SearchTokenizer } from './SearchTokenizer';
|
|
8
|
+
|
|
9
|
+
/** Who a row being written belongs to — the data key its values encrypt under. */
|
|
10
|
+
export type EncryptionWriteContext = { keyOwner: string };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The transparent encrypt/decrypt seam, invoked by `RecordSerializer` on every write's
|
|
14
|
+
* serialize and every read's deserialize — the same per-column hook layer every caller
|
|
15
|
+
* already passes through, so services, watchers, migrations, and queued client
|
|
16
|
+
* transactions get encryption without knowing it exists.
|
|
17
|
+
*
|
|
18
|
+
* - Serialize: after the columns' own `serialize` steps, values of encrypted columns are
|
|
19
|
+
* replaced with self-describing envelopes (`EncryptionEnvelope`), and the searchable /
|
|
20
|
+
* sort companions are written beside them in the same statement.
|
|
21
|
+
* - Deserialize: before the columns' own `deserialize` steps, envelope values are decrypted
|
|
22
|
+
* (the envelope names its key — owner + version — so no context is needed) and
|
|
23
|
+
* framework-derived companion columns are dropped. A non-envelope value in an encrypted
|
|
24
|
+
* column passes through unchanged — the online-adoption transition: rows written before
|
|
25
|
+
* the encrypt backfill stay readable while the walker converges them.
|
|
26
|
+
*
|
|
27
|
+
* Server-only: this seam executes where the database driver runs. Browser code proxies
|
|
28
|
+
* through `DbService` and receives decrypted values over the authenticated service.
|
|
29
|
+
*/
|
|
30
|
+
export class EncryptionRecordHooks {
|
|
31
|
+
private encryptedColumns = new EncryptedColumns();
|
|
32
|
+
private envelope = new EncryptionEnvelope();
|
|
33
|
+
private tokenizer = new SearchTokenizer();
|
|
34
|
+
|
|
35
|
+
/** Encrypt encrypted-column values in `serialized` (column-name-keyed) and add companions. */
|
|
36
|
+
async onSerialize(table: Table<any>, serialized: SerializedRecord, context?: EncryptionWriteContext): Promise<void> {
|
|
37
|
+
this.encryptedColumns.ensureSchema(table);
|
|
38
|
+
const touchedProps = this.encryptedColumns
|
|
39
|
+
.encryptedProps(table)
|
|
40
|
+
.filter((prop) => ((table.columns as any)[prop] as { name: string }).name in serialized);
|
|
41
|
+
if (touchedProps.length === 0) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
this.assertServerSide();
|
|
46
|
+
let writeKey: DataKeyMaterial | undefined;
|
|
47
|
+
for (const prop of touchedProps) {
|
|
48
|
+
const column = (table.columns as any)[prop];
|
|
49
|
+
const config = this.encryptedColumns.configFor(table, prop)!;
|
|
50
|
+
const value = serialized[column.name];
|
|
51
|
+
if (value === null) {
|
|
52
|
+
// Null stays null (IS NULL queries keep working); companions null out with it.
|
|
53
|
+
if (config.searchable === 'equality') {
|
|
54
|
+
serialized[`${column.name}_enc_eq`] = null;
|
|
55
|
+
}
|
|
56
|
+
if (config.sortKey) {
|
|
57
|
+
serialized[`${column.name}_enc_srt`] = null;
|
|
58
|
+
}
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (typeof value !== 'string') {
|
|
63
|
+
throw new EncryptedColumnConfigError(
|
|
64
|
+
`(${table.name}.${prop}) encrypted columns must serialize to a string; ` +
|
|
65
|
+
`got ${typeof value}. Declare 'encrypted' on a StringColumn-family column.`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!writeKey) {
|
|
70
|
+
if (!context?.keyOwner) {
|
|
71
|
+
throw new EncryptedColumnConfigError(
|
|
72
|
+
`(${table.name}) A write touches encrypted column '${prop}' but no key owner was ` +
|
|
73
|
+
`resolved. Writes to encrypted columns must resolve the row's scope owner ` +
|
|
74
|
+
`(the row's 'scope' value, or DbEncryptionConfig.resolveKeyOwner).`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
writeKey = await new DataKeyStore().getWriteKey(context.keyOwner);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
serialized[column.name] = this.envelope.encrypt(value, writeKey);
|
|
81
|
+
if (config.searchable === 'equality') {
|
|
82
|
+
serialized[`${column.name}_enc_eq`] = this.tokenizer.equalityFingerprint(value, writeKey.indexKey);
|
|
83
|
+
}
|
|
84
|
+
if (config.sortKey) {
|
|
85
|
+
serialized[`${column.name}_enc_srt`] = this.tokenizer.sortPrefix(value, config.sortKey.revealPrefix);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A copy of `serializedRecord` with framework companion columns dropped and every
|
|
92
|
+
* envelope value decrypted (any column — self-describing ciphertext also covers the
|
|
93
|
+
* decrypt-out transition, where the config no longer marks the column).
|
|
94
|
+
*/
|
|
95
|
+
async onDeserialize(table: Table<any>, serializedRecord: SerializedRecord): Promise<SerializedRecord> {
|
|
96
|
+
this.encryptedColumns.ensureSchema(table);
|
|
97
|
+
const prepared: SerializedRecord = {};
|
|
98
|
+
const internalColumnNames = this.internalColumnNames(table);
|
|
99
|
+
let decrypted = false;
|
|
100
|
+
for (const columnName of Object.keys(serializedRecord)) {
|
|
101
|
+
if (internalColumnNames.has(columnName)) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const value = serializedRecord[columnName];
|
|
106
|
+
const parsed = this.envelope.parse(value);
|
|
107
|
+
if (!parsed) {
|
|
108
|
+
prepared[columnName] = value;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this.assertServerSide();
|
|
113
|
+
const key = await new DataKeyStore().getKeyByVersion(parsed.owner, parsed.version);
|
|
114
|
+
prepared[columnName] = this.envelope.decrypt(value as string, key);
|
|
115
|
+
decrypted = true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return decrypted || internalColumnNames.size > 0 ? prepared : serializedRecord;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The key owner for a row being written — its permission-source scope owner:
|
|
123
|
+
* `DbEncryptionConfig.resolveKeyOwner` when the deployment supplies one (richer sharing
|
|
124
|
+
* models map scope→owner there), else the row's `scope` column value (the framework's
|
|
125
|
+
* scope columns hold the owning user's id).
|
|
126
|
+
*/
|
|
127
|
+
async resolveKeyOwnerForWrite(table: Table<any>, record: any): Promise<string> {
|
|
128
|
+
const config = getDbEncryptionConfig();
|
|
129
|
+
if (config.resolveKeyOwner) {
|
|
130
|
+
const owner = await config.resolveKeyOwner({ table, record });
|
|
131
|
+
if (owner) {
|
|
132
|
+
return owner;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (typeof record.scope === 'string' && record.scope.length > 0) {
|
|
137
|
+
return record.scope;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
throw new EncryptedColumnConfigError(
|
|
141
|
+
`(${table.name}) Cannot resolve the key owner for a write to an encrypted column: the row ` +
|
|
142
|
+
`carries no 'scope' value and DbEncryptionConfig.resolveKeyOwner resolved nothing. ` +
|
|
143
|
+
`Scoped tables get this automatically; other tables must supply resolveKeyOwner.`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private internalColumnNames(table: Table<any>): Set<string> {
|
|
148
|
+
const names = new Set<string>();
|
|
149
|
+
for (const prop of Object.keys(table.columns)) {
|
|
150
|
+
const column = (table.columns as any)[prop];
|
|
151
|
+
if (this.encryptedColumns.isInternalColumn(column)) {
|
|
152
|
+
names.add(column.name);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return names;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private assertServerSide(): void {
|
|
160
|
+
if (typeof self !== 'undefined') {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`Column encryption executes where the database driver runs (the server); the browser ` +
|
|
163
|
+
`must reach encrypted tables through DbService.`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { QueryBuilder } from '@proteinjs/db-query';
|
|
2
|
+
import type { Table } from '../Table';
|
|
3
|
+
import type { Record } from '../Record';
|
|
4
|
+
import { EncryptedColumns, EncryptionSearchToken } from './EncryptedColumns';
|
|
5
|
+
import { DataKeyStore } from './DataKeyStore';
|
|
6
|
+
import { SearchTokenizer } from './SearchTokenizer';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Search-token upkeep for `encrypted: { searchable: 'contains' }` columns: the same write
|
|
10
|
+
* that stores a row's ciphertext also derives the value's search tokens (words, trigrams,
|
|
11
|
+
* short prefixes — `SearchTokenizer`), fingerprints each with the row owner's index key,
|
|
12
|
+
* and maintains them as rows of the derived token table (`EncryptedColumns.tokenTableFor`)
|
|
13
|
+
* — the portable inverted index the query translator's candidate step reads.
|
|
14
|
+
*
|
|
15
|
+
* Invoked by `Db` around its DML (insert/update/delete), through a SYSTEM Db riding the
|
|
16
|
+
* same driver and ambient transaction — inside a transaction, token rows commit or roll
|
|
17
|
+
* back with the row; outside one, the token write follows the row write immediately.
|
|
18
|
+
*/
|
|
19
|
+
export class EncryptionTokenMaintenance {
|
|
20
|
+
private static readonly ID_CHUNK_SIZE = 500;
|
|
21
|
+
private encryptedColumns = new EncryptedColumns();
|
|
22
|
+
private tokenizer = new SearchTokenizer();
|
|
23
|
+
|
|
24
|
+
/** Write token rows for the contains-searchable columns present in an inserted record. */
|
|
25
|
+
async afterInsert(table: Table<any>, record: any, keyOwner: string, systemDb: TokenMaintenanceDb): Promise<void> {
|
|
26
|
+
const touched = this.touchedContainsProps(table, record);
|
|
27
|
+
if (touched.length === 0) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
await this.writeTokenRows(table, [record.id], record, touched, keyOwner, systemDb);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Rewrite token rows of the touched contains-searchable columns for the updated row ids. */
|
|
35
|
+
async afterUpdate(
|
|
36
|
+
table: Table<any>,
|
|
37
|
+
recordIds: string[],
|
|
38
|
+
record: any,
|
|
39
|
+
keyOwner: string,
|
|
40
|
+
systemDb: TokenMaintenanceDb
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
const touched = this.touchedContainsProps(table, record);
|
|
43
|
+
if (touched.length === 0 || recordIds.length === 0) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const tokenTable = this.encryptedColumns.tokenTableFor(table)!;
|
|
48
|
+
const touchedColumnNames = touched.map((prop) => ((table.columns as any)[prop] as { name: string }).name);
|
|
49
|
+
for (const idsChunk of this.chunk(recordIds, EncryptionTokenMaintenance.ID_CHUNK_SIZE)) {
|
|
50
|
+
const deleteQb = new QueryBuilder<EncryptionSearchToken>(tokenTable.name)
|
|
51
|
+
.condition({ field: 'recordId', operator: 'IN', value: idsChunk })
|
|
52
|
+
.condition({ field: 'columnName', operator: 'IN', value: touchedColumnNames });
|
|
53
|
+
await systemDb.delete(tokenTable, deleteQb);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await this.writeTokenRows(table, recordIds, record, touched, keyOwner, systemDb);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Remove every token row of deleted records. */
|
|
60
|
+
async afterDelete(table: Table<any>, deletedIds: string[], systemDb: TokenMaintenanceDb): Promise<void> {
|
|
61
|
+
const tokenTable = this.encryptedColumns.tokenTableFor(table);
|
|
62
|
+
if (!tokenTable || deletedIds.length === 0) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const idsChunk of this.chunk(deletedIds, EncryptionTokenMaintenance.ID_CHUNK_SIZE)) {
|
|
67
|
+
const deleteQb = new QueryBuilder<EncryptionSearchToken>(tokenTable.name).condition({
|
|
68
|
+
field: 'recordId',
|
|
69
|
+
operator: 'IN',
|
|
70
|
+
value: idsChunk,
|
|
71
|
+
});
|
|
72
|
+
await systemDb.delete(tokenTable, deleteQb);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private async writeTokenRows(
|
|
77
|
+
table: Table<any>,
|
|
78
|
+
recordIds: string[],
|
|
79
|
+
record: any,
|
|
80
|
+
touchedProps: string[],
|
|
81
|
+
keyOwner: string,
|
|
82
|
+
systemDb: TokenMaintenanceDb
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
const tokenTable = this.encryptedColumns.tokenTableFor(table)!;
|
|
85
|
+
const writeKey = await new DataKeyStore().getWriteKey(keyOwner);
|
|
86
|
+
for (const prop of touchedProps) {
|
|
87
|
+
const value = record[prop];
|
|
88
|
+
if (value === null) {
|
|
89
|
+
continue; // null values carry no tokens
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const columnName = ((table.columns as any)[prop] as { name: string }).name;
|
|
93
|
+
const fingerprints = this.tokenizer.fingerprints(this.tokenizer.tokensForValue(String(value)), writeKey.indexKey);
|
|
94
|
+
for (const recordId of recordIds) {
|
|
95
|
+
for (const token of fingerprints) {
|
|
96
|
+
await systemDb.insert(tokenTable, { recordId, columnName, token } as Omit<
|
|
97
|
+
EncryptionSearchToken,
|
|
98
|
+
keyof Record
|
|
99
|
+
>);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private touchedContainsProps(table: Table<any>, record: any): string[] {
|
|
106
|
+
return this.encryptedColumns.containsProps(table).filter((prop) => typeof record[prop] !== 'undefined');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private chunk<T>(items: T[], size: number): T[][] {
|
|
110
|
+
const chunks: T[][] = [];
|
|
111
|
+
for (let i = 0; i < items.length; i += size) {
|
|
112
|
+
chunks.push(items.slice(i, i + size));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return chunks;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The Db surface token maintenance needs (a system Db instance — see class doc). */
|
|
120
|
+
export interface TokenMaintenanceDb {
|
|
121
|
+
insert<T extends Record>(table: Table<T>, record: Omit<T, keyof Record>): Promise<T>;
|
|
122
|
+
delete<T extends Record>(table: Table<T>, query: QueryBuilder<T>): Promise<number>;
|
|
123
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto';
|
|
2
|
+
import { Base64Url } from './Base64Url';
|
|
3
|
+
import { MasterKeyProvider } from './MasterKeyProvider';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A `MasterKeyProvider` holding the master key in process memory. For tests and local
|
|
7
|
+
* development only — a production deployment keeps its master key in a vault
|
|
8
|
+
* (`GcpKmsMasterKeyProvider`), where the key material never enters the app process.
|
|
9
|
+
*
|
|
10
|
+
* Wraps with AES-256-GCM. Constructing with the same `secret` yields a provider that can
|
|
11
|
+
* unwrap keys wrapped by a previous instance (test fixtures across processes); constructing
|
|
12
|
+
* with no secret generates a random master key for the life of the process.
|
|
13
|
+
*/
|
|
14
|
+
export class InMemoryMasterKeyProvider implements MasterKeyProvider {
|
|
15
|
+
private masterKey: Buffer;
|
|
16
|
+
|
|
17
|
+
constructor(secret?: string) {
|
|
18
|
+
this.masterKey = secret ? createHash('sha256').update(secret).digest() : randomBytes(32);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async wrapDataKey(plaintextKeyMaterial: Buffer): Promise<string> {
|
|
22
|
+
const iv = randomBytes(12);
|
|
23
|
+
const cipher = createCipheriv('aes-256-gcm', this.masterKey, iv);
|
|
24
|
+
const ciphertext = Buffer.concat([cipher.update(plaintextKeyMaterial), cipher.final()]);
|
|
25
|
+
const authTag = cipher.getAuthTag();
|
|
26
|
+
return Base64Url.encode(Buffer.concat([iv, authTag, ciphertext]));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async unwrapDataKey(wrappedKeyMaterial: string): Promise<Buffer> {
|
|
30
|
+
const bytes = Base64Url.decode(wrappedKeyMaterial);
|
|
31
|
+
const iv = bytes.subarray(0, 12);
|
|
32
|
+
const authTag = bytes.subarray(12, 28);
|
|
33
|
+
const ciphertext = bytes.subarray(28);
|
|
34
|
+
const decipher = createDecipheriv('aes-256-gcm', this.masterKey, iv);
|
|
35
|
+
decipher.setAuthTag(authTag);
|
|
36
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
getMasterKeyId(): string {
|
|
40
|
+
return 'in-memory';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The master-key seam of column encryption (see `EncryptedColumnConfig`).
|
|
3
|
+
*
|
|
4
|
+
* One master key exists per deployment, held in a key vault (Cloud KMS in production, an
|
|
5
|
+
* in-memory key in tests). The master key never encrypts row data directly — it wraps
|
|
6
|
+
* (encrypts) each user's randomly generated data key, and those per-user data keys encrypt
|
|
7
|
+
* the rows in that user's scope. The vault is therefore called once per data key
|
|
8
|
+
* (unwrap-once-per-key caching lives in `DataKeyStore`), never per row.
|
|
9
|
+
*
|
|
10
|
+
* Implementations:
|
|
11
|
+
* - `InMemoryMasterKeyProvider` (this package) — tests and local development.
|
|
12
|
+
* - `GcpKmsMasterKeyProvider` (`@proteinjs/db-encryption-driver-gcp-kms`) — Cloud KMS.
|
|
13
|
+
*/
|
|
14
|
+
export interface MasterKeyProvider {
|
|
15
|
+
/**
|
|
16
|
+
* Encrypt (wrap) raw data-key material under the master key.
|
|
17
|
+
* @returns an opaque string safe to store in the data-key table
|
|
18
|
+
*/
|
|
19
|
+
wrapDataKey(plaintextKeyMaterial: Buffer): Promise<string>;
|
|
20
|
+
|
|
21
|
+
/** Decrypt (unwrap) previously wrapped data-key material. */
|
|
22
|
+
unwrapDataKey(wrappedKeyMaterial: string): Promise<Buffer>;
|
|
23
|
+
|
|
24
|
+
/** Stable identifier of the master key (a KMS resource name, or 'in-memory') — observability only. */
|
|
25
|
+
getMasterKeyId(): string;
|
|
26
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { createHmac } from 'crypto';
|
|
2
|
+
import { Base64Url } from './Base64Url';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Search tokens and keyed fingerprints for encrypted columns
|
|
6
|
+
* (`encrypted: { searchable: 'contains' | 'equality' }`).
|
|
7
|
+
*
|
|
8
|
+
* Definitions:
|
|
9
|
+
* - **Search token** — a normalized fragment of a text value: its lowercased words, each
|
|
10
|
+
* word's trigrams (three-letter fragments), and each word's 1- and 2-letter prefixes
|
|
11
|
+
* (so 1–2 character queries behave as word-prefix search).
|
|
12
|
+
* - **Keyed fingerprint (HMAC-SHA256)** — computable only with the owner's index key
|
|
13
|
+
* (`DataKeyMaterial.indexKey`); without it, stored fingerprints can be neither created
|
|
14
|
+
* nor reversed, so raw database access cannot probe ("does any title contain X?").
|
|
15
|
+
*
|
|
16
|
+
* Query-side cover: a candidate row must hold ALL of the query's fragment fingerprints.
|
|
17
|
+
* The cover can over-match (fragments present but not contiguous) and — for query words of
|
|
18
|
+
* ≥3 characters — can never under-match; the query layer verifies every candidate against
|
|
19
|
+
* the decrypted value before returning it (the pg_trgm recheck semantics), so results are
|
|
20
|
+
* exact. Documented narrowing: a 1–2 character query WORD matches word beginnings only
|
|
21
|
+
* (a 1–2 character mid-word substring is unsupported and behaves as prefix search).
|
|
22
|
+
*
|
|
23
|
+
* Equality fingerprints cover the EXACT serialized value (no normalization): `=` keeps its
|
|
24
|
+
* native case-sensitive semantics with no recheck, and a unique index on the fingerprint
|
|
25
|
+
* column enforces per-owner value uniqueness.
|
|
26
|
+
*/
|
|
27
|
+
export class SearchTokenizer {
|
|
28
|
+
/** All write-side tokens of `value` (deduplicated, un-fingerprinted). */
|
|
29
|
+
tokensForValue(value: string): string[] {
|
|
30
|
+
const tokens = new Set<string>();
|
|
31
|
+
for (const word of this.words(value)) {
|
|
32
|
+
tokens.add(`w:${word}`);
|
|
33
|
+
tokens.add(`p:${word.slice(0, 1)}`);
|
|
34
|
+
if (word.length >= 2) {
|
|
35
|
+
tokens.add(`p:${word.slice(0, 2)}`);
|
|
36
|
+
}
|
|
37
|
+
for (const trigram of this.trigrams(word)) {
|
|
38
|
+
tokens.add(`t:${trigram}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return Array.from(tokens);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The query-side fragment cover of `query`: for each query word, its trigrams (≥3 chars)
|
|
47
|
+
* or its prefix token (1–2 chars). Every fragment must match for a row to be a candidate.
|
|
48
|
+
*/
|
|
49
|
+
fragmentsForQuery(query: string): string[] {
|
|
50
|
+
const fragments = new Set<string>();
|
|
51
|
+
for (const word of this.words(query)) {
|
|
52
|
+
if (word.length >= 3) {
|
|
53
|
+
for (const trigram of this.trigrams(word)) {
|
|
54
|
+
fragments.add(`t:${trigram}`);
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
fragments.add(`p:${word}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return Array.from(fragments);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Keyed fingerprint of one token. */
|
|
65
|
+
fingerprint(token: string, indexKey: Buffer): string {
|
|
66
|
+
return Base64Url.encode(createHmac('sha256', indexKey).update(token, 'utf8').digest());
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
fingerprints(tokens: string[], indexKey: Buffer): string[] {
|
|
70
|
+
return tokens.map((token) => this.fingerprint(token, indexKey));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Whole-value equality fingerprint — over the exact serialized value (see class doc). */
|
|
74
|
+
equalityFingerprint(value: string, indexKey: Buffer): string {
|
|
75
|
+
return Base64Url.encode(createHmac('sha256', indexKey).update(`eq:${value}`, 'utf8').digest());
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The declared bounded reveal for `encrypted: { sortKey: { revealPrefix: N } }`: the
|
|
80
|
+
* normalized (lowercased) first N characters, stored beside the ciphertext for native
|
|
81
|
+
* ORDER BY. Raw database access can read those N characters — the declared, documented
|
|
82
|
+
* leak, chosen at the schema, never the default.
|
|
83
|
+
*/
|
|
84
|
+
sortPrefix(value: string, revealPrefix: number): string {
|
|
85
|
+
return value.toLowerCase().slice(0, revealPrefix);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private words(text: string): string[] {
|
|
89
|
+
return text
|
|
90
|
+
.toLowerCase()
|
|
91
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
92
|
+
.filter((word) => word.length > 0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private trigrams(word: string): string[] {
|
|
96
|
+
const trigrams: string[] = [];
|
|
97
|
+
for (let i = 0; i + 3 <= word.length; i++) {
|
|
98
|
+
trigrams.push(word.slice(i, i + 3));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return trigrams;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -6,6 +6,8 @@ import { SchemaMetadata } from './SchemaMetadata';
|
|
|
6
6
|
import { DbDriver } from '../Db';
|
|
7
7
|
import { DynamicReferenceColumn, DynamicReferenceTableNameColumn } from '../Columns';
|
|
8
8
|
import { StatementConfigFactory } from '../StatementConfigFactory';
|
|
9
|
+
import { EncryptedColumns } from '../encryption/EncryptedColumns';
|
|
10
|
+
import { findDbEncryptionConfig } from '../encryption/DbEncryptionConfig';
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* Thrown by the pre-sync duplicate check when a unique index is about to be added to an existing
|
|
@@ -75,7 +77,7 @@ export class TableManager {
|
|
|
75
77
|
* just-created table sees it live.
|
|
76
78
|
*/
|
|
77
79
|
async loadTables(): Promise<void> {
|
|
78
|
-
const tables = getTables();
|
|
80
|
+
const tables = this.prepareTablesForLoad(getTables());
|
|
79
81
|
const absentTables: Table<any>[] = [];
|
|
80
82
|
const existingTables: Table<any>[] = [];
|
|
81
83
|
for (const table of tables) {
|
|
@@ -103,6 +105,12 @@ export class TableManager {
|
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
async loadTable(table: Table<any>): Promise<void> {
|
|
108
|
+
for (const preparedTable of this.prepareTablesForLoad([table])) {
|
|
109
|
+
await this.loadSingleTable(preparedTable);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private async loadSingleTable(table: Table<any>): Promise<void> {
|
|
106
114
|
this.validateDynamicReferenceColumns(table);
|
|
107
115
|
|
|
108
116
|
if (await this.tableExists(table)) {
|
|
@@ -118,6 +126,40 @@ export class TableManager {
|
|
|
118
126
|
}
|
|
119
127
|
}
|
|
120
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Column-encryption schema pass (`ColumnOptions.encrypted`): validate declarations,
|
|
131
|
+
* inject the derived companion columns, and append each table's derived search-token
|
|
132
|
+
* table so it is created/altered alongside its base table. When the deployment turns on
|
|
133
|
+
* `requireEncryptedDeclarations` (or `DB_REQUIRE_ENCRYPTED_DECLARATIONS=true`), every
|
|
134
|
+
* text-holding column must declare `encrypted` — registration fails loudly here otherwise.
|
|
135
|
+
*/
|
|
136
|
+
private prepareTablesForLoad(tables: Table<any>[]): Table<any>[] {
|
|
137
|
+
const encryptedColumns = new EncryptedColumns();
|
|
138
|
+
const requireDeclarations = this.requireEncryptedDeclarations();
|
|
139
|
+
const prepared: Table<any>[] = [];
|
|
140
|
+
for (const table of tables) {
|
|
141
|
+
encryptedColumns.ensureSchema(table);
|
|
142
|
+
if (requireDeclarations) {
|
|
143
|
+
encryptedColumns.validateDeclarations(table);
|
|
144
|
+
}
|
|
145
|
+
prepared.push(table);
|
|
146
|
+
const tokenTable = encryptedColumns.tokenTableFor(table);
|
|
147
|
+
if (tokenTable) {
|
|
148
|
+
prepared.push(tokenTable);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return prepared;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private requireEncryptedDeclarations(): boolean {
|
|
156
|
+
if (getEnvVar('DB_REQUIRE_ENCRYPTED_DECLARATIONS') === 'true') {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return !!findDbEncryptionConfig()?.requireEncryptedDeclarations;
|
|
161
|
+
}
|
|
162
|
+
|
|
121
163
|
private async alterTableIfChanged(table: Table<any>): Promise<void> {
|
|
122
164
|
const tableChanges = await this.getTableChanges(table);
|
|
123
165
|
if (this.shouldAlterTable(tableChanges)) {
|