@proteinjs/db 1.35.1 → 1.37.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 +22 -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/Columns.d.ts +5 -1
- package/dist/src/Columns.d.ts.map +1 -1
- package/dist/src/Columns.js +2 -1
- package/dist/src/Columns.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/Columns.ts +6 -2
- 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,305 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
2
|
+
import { Logger } from '@proteinjs/logger';
|
|
3
|
+
import { getDbEncryptionConfig } from './DbEncryptionConfig';
|
|
4
|
+
import { DataEncryptionKey, DataEncryptionKeyTable } from './DataEncryptionKeyTable';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Decrypt was asked for a key that does not exist — most often because the owner's keys were
|
|
8
|
+
* crypto-shredded (account deletion): the ciphertext is permanently unreadable, by design.
|
|
9
|
+
*/
|
|
10
|
+
export class EncryptionKeyUnavailableError extends Error {
|
|
11
|
+
constructor(owner: string, version: number) {
|
|
12
|
+
super(
|
|
13
|
+
`No data key exists for owner '${owner}' version ${version}. If the owner's keys were ` +
|
|
14
|
+
`deleted (crypto-shred), rows encrypted under them are permanently unreadable.`
|
|
15
|
+
);
|
|
16
|
+
this.name = 'EncryptionKeyUnavailableError';
|
|
17
|
+
Object.setPrototypeOf(this, EncryptionKeyUnavailableError.prototype);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Unwrapped material of one data-key version. */
|
|
22
|
+
export interface DataKeyMaterial {
|
|
23
|
+
owner: string;
|
|
24
|
+
version: number;
|
|
25
|
+
/** AES-256 key for row values (first half of the 64-byte material). */
|
|
26
|
+
cipherKey: Buffer;
|
|
27
|
+
/** HMAC key for search-token fingerprints (second half). */
|
|
28
|
+
indexKey: Buffer;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface OwnerKeyRowsCacheEntry {
|
|
32
|
+
fetchedAt: number;
|
|
33
|
+
rows: Pick<DataEncryptionKey, 'owner' | 'version' | 'wrappedKey' | 'status'>[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface MaterialCacheEntry {
|
|
37
|
+
cachedAt: number;
|
|
38
|
+
material: DataKeyMaterial;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface DataKeyCaches {
|
|
42
|
+
ownerRows: Map<string, OwnerKeyRowsCacheEntry>;
|
|
43
|
+
material: Map<string, MaterialCacheEntry>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Per-owner data keys for column encryption: creation, unwrap-once caching, rotation,
|
|
48
|
+
* retirement, and crypto-shred. Key rows live in `DataEncryptionKeyTable`, wrapped by the
|
|
49
|
+
* deployment's `MasterKeyProvider`; the vault is called once per key (then cached), never
|
|
50
|
+
* per row.
|
|
51
|
+
*
|
|
52
|
+
* All database access runs as system, OUTSIDE any ambient transaction: a key row must be
|
|
53
|
+
* durable independent of the caller's transaction outcome, because unwrapped material is
|
|
54
|
+
* cached in-process the moment it exists — a key created inside a transaction that later
|
|
55
|
+
* rolls back would leave the cache claiming a key the database lost. An orphaned key row
|
|
56
|
+
* from a rolled-back first write is harmless (a key no envelope names).
|
|
57
|
+
*
|
|
58
|
+
* The caches live on the global object (the per-package-install duplicate-module pattern —
|
|
59
|
+
* see `Db.getDefaultDbDriver`), so every live copy of this package shares one cache.
|
|
60
|
+
*/
|
|
61
|
+
export class DataKeyStore {
|
|
62
|
+
/** Unwrapped material cache TTL — bounds how long a shredded key can linger in a warm process. */
|
|
63
|
+
private static readonly MATERIAL_TTL_MS = 10 * 60 * 1000;
|
|
64
|
+
/** Owner key-row list TTL — bounds cross-process staleness of the active-version set. */
|
|
65
|
+
private static readonly OWNER_ROWS_TTL_MS = 60 * 1000;
|
|
66
|
+
private static readonly CACHES_GLOBAL_KEY = '__proteinjs_db_dataKeyCaches';
|
|
67
|
+
private static readonly KEY_MATERIAL_BYTES = 64;
|
|
68
|
+
private logger = new Logger({ name: this.constructor.name });
|
|
69
|
+
private table = new DataEncryptionKeyTable();
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The key that encrypts new writes for `owner`: the highest active version, created on
|
|
73
|
+
* first use (each owner gets a randomly generated data key, wrapped by the master key).
|
|
74
|
+
*/
|
|
75
|
+
async getWriteKey(owner: string): Promise<DataKeyMaterial> {
|
|
76
|
+
this.requireOwner(owner);
|
|
77
|
+
let rows = await this.getOwnerRows(owner);
|
|
78
|
+
let active = this.highestActive(rows);
|
|
79
|
+
if (!active) {
|
|
80
|
+
await this.createKeyVersion(owner, 1);
|
|
81
|
+
this.evictOwner(owner);
|
|
82
|
+
rows = await this.getOwnerRows(owner);
|
|
83
|
+
active = this.highestActive(rows);
|
|
84
|
+
if (!active) {
|
|
85
|
+
throw new EncryptionKeyUnavailableError(owner, 1);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return await this.unwrap(active);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The key an envelope names — any status; decrypt must work until the key is shredded. */
|
|
93
|
+
async getKeyByVersion(owner: string, version: number): Promise<DataKeyMaterial> {
|
|
94
|
+
const rows = await this.getOwnerRows(owner);
|
|
95
|
+
const row = rows.find((candidate) => candidate.version === version);
|
|
96
|
+
if (!row) {
|
|
97
|
+
// The row list may be cached from before this version existed (e.g. a fresh rotation
|
|
98
|
+
// in another process) — refresh once before declaring the key gone.
|
|
99
|
+
this.evictOwner(owner);
|
|
100
|
+
const freshRows = await this.getOwnerRows(owner);
|
|
101
|
+
const freshRow = freshRows.find((candidate) => candidate.version === version);
|
|
102
|
+
if (!freshRow) {
|
|
103
|
+
throw new EncryptionKeyUnavailableError(owner, version);
|
|
104
|
+
}
|
|
105
|
+
return await this.unwrap(freshRow);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return await this.unwrap(row);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The index keys a query fingerprints with: every ACTIVE version of every accessible
|
|
113
|
+
* owner (normally one per owner; two during a rotation window). Owners with no keys yet
|
|
114
|
+
* contribute nothing — they cannot have encrypted rows.
|
|
115
|
+
*/
|
|
116
|
+
async getQueryIndexKeys(owners: string[]): Promise<DataKeyMaterial[]> {
|
|
117
|
+
const keys: DataKeyMaterial[] = [];
|
|
118
|
+
for (const owner of owners) {
|
|
119
|
+
const rows = await this.getOwnerRows(owner);
|
|
120
|
+
for (const row of rows) {
|
|
121
|
+
if (row.status === 'active') {
|
|
122
|
+
keys.push(await this.unwrap(row));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return keys;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Mint the next key version for `owner` (new writes use it immediately; the old version
|
|
132
|
+
* stays active — still fingerprinting queries — until a rotation walk
|
|
133
|
+
* (`EncryptionLifecycleWalker` mode 'rotate-keys') has rewritten every row, after which
|
|
134
|
+
* the caller retires it via `retireKeyVersion`).
|
|
135
|
+
* @returns the new version number
|
|
136
|
+
*/
|
|
137
|
+
async rotateKey(owner: string): Promise<number> {
|
|
138
|
+
this.requireOwner(owner);
|
|
139
|
+
const rows = await this.getOwnerRows(owner);
|
|
140
|
+
const highest = rows.reduce((max, row) => Math.max(max, row.version), 0);
|
|
141
|
+
const newVersion = highest + 1;
|
|
142
|
+
await this.createKeyVersion(owner, newVersion);
|
|
143
|
+
this.evictOwner(owner);
|
|
144
|
+
return newVersion;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Retire a version: envelopes naming it still decrypt; queries and writes stop using it. */
|
|
148
|
+
async retireKeyVersion(owner: string, version: number): Promise<void> {
|
|
149
|
+
const db = await this.systemDb();
|
|
150
|
+
await db.update(
|
|
151
|
+
this.table,
|
|
152
|
+
{ status: 'retired' } as Partial<DataEncryptionKey>,
|
|
153
|
+
{
|
|
154
|
+
owner,
|
|
155
|
+
version,
|
|
156
|
+
} as Partial<DataEncryptionKey>
|
|
157
|
+
);
|
|
158
|
+
this.evictOwner(owner);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Crypto-shred: delete every key version of `owner` and evict the caches. Every envelope
|
|
163
|
+
* naming the owner — in live rows and in every backup — becomes permanently unreadable.
|
|
164
|
+
*/
|
|
165
|
+
async shredOwnerKeys(owner: string): Promise<number> {
|
|
166
|
+
const db = await this.systemDb();
|
|
167
|
+
const deleted = await db.delete(this.table, { owner } as Partial<DataEncryptionKey>);
|
|
168
|
+
this.evictOwner(owner);
|
|
169
|
+
const caches = this.caches();
|
|
170
|
+
for (const cacheKey of Array.from(caches.material.keys())) {
|
|
171
|
+
if (cacheKey.startsWith(`${owner}:`)) {
|
|
172
|
+
caches.material.delete(cacheKey);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
this.logger.info({ message: `Crypto-shredded data keys`, obj: { owner, deleted } });
|
|
177
|
+
return deleted;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private async createKeyVersion(owner: string, version: number): Promise<void> {
|
|
181
|
+
const config = getDbEncryptionConfig();
|
|
182
|
+
const material = randomBytes(DataKeyStore.KEY_MATERIAL_BYTES);
|
|
183
|
+
const wrappedKey = await config.masterKeyProvider.wrapDataKey(material);
|
|
184
|
+
const db = await this.systemDb();
|
|
185
|
+
try {
|
|
186
|
+
await db.insert(this.table, { owner, version, wrappedKey, status: 'active' } as Omit<
|
|
187
|
+
DataEncryptionKey,
|
|
188
|
+
'id' | 'created' | 'updated'
|
|
189
|
+
>);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
// Named race: two first writes for the same owner both create version 1; the unique
|
|
192
|
+
// (owner, version) index rejects the loser. Re-read — if the winner's key is there,
|
|
193
|
+
// use it; otherwise the failure was real.
|
|
194
|
+
this.evictOwner(owner);
|
|
195
|
+
const rows = await this.getOwnerRows(owner);
|
|
196
|
+
if (!rows.some((row) => row.version === version)) {
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private async getOwnerRows(owner: string): Promise<OwnerKeyRowsCacheEntry['rows']> {
|
|
203
|
+
const caches = this.caches();
|
|
204
|
+
const cached = caches.ownerRows.get(owner);
|
|
205
|
+
if (cached && Date.now() - cached.fetchedAt < DataKeyStore.OWNER_ROWS_TTL_MS) {
|
|
206
|
+
return cached.rows;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const db = await this.systemDb();
|
|
210
|
+
const rows = await db.query(this.table, { owner } as Partial<DataEncryptionKey>);
|
|
211
|
+
const entry: OwnerKeyRowsCacheEntry = {
|
|
212
|
+
fetchedAt: Date.now(),
|
|
213
|
+
rows: rows.map((row) => ({
|
|
214
|
+
owner: row.owner,
|
|
215
|
+
version: row.version,
|
|
216
|
+
wrappedKey: row.wrappedKey,
|
|
217
|
+
status: row.status,
|
|
218
|
+
})),
|
|
219
|
+
};
|
|
220
|
+
caches.ownerRows.set(owner, entry);
|
|
221
|
+
return entry.rows;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private async unwrap(row: Pick<DataEncryptionKey, 'owner' | 'version' | 'wrappedKey'>): Promise<DataKeyMaterial> {
|
|
225
|
+
const caches = this.caches();
|
|
226
|
+
const cacheKey = `${row.owner}:${row.version}`;
|
|
227
|
+
const cached = caches.material.get(cacheKey);
|
|
228
|
+
if (cached && Date.now() - cached.cachedAt < DataKeyStore.MATERIAL_TTL_MS) {
|
|
229
|
+
return cached.material;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const config = getDbEncryptionConfig();
|
|
233
|
+
const materialBytes = await config.masterKeyProvider.unwrapDataKey(row.wrappedKey);
|
|
234
|
+
if (materialBytes.length !== DataKeyStore.KEY_MATERIAL_BYTES) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`Data key material for owner '${row.owner}' version ${row.version} has unexpected length ` +
|
|
237
|
+
`${materialBytes.length} (expected ${DataKeyStore.KEY_MATERIAL_BYTES}) — wrong master key?`
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const material: DataKeyMaterial = {
|
|
242
|
+
owner: row.owner,
|
|
243
|
+
version: row.version,
|
|
244
|
+
cipherKey: materialBytes.subarray(0, 32),
|
|
245
|
+
indexKey: materialBytes.subarray(32, 64),
|
|
246
|
+
};
|
|
247
|
+
caches.material.set(cacheKey, { cachedAt: Date.now(), material });
|
|
248
|
+
return material;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private highestActive(rows: OwnerKeyRowsCacheEntry['rows']) {
|
|
252
|
+
return rows.filter((row) => row.status === 'active').sort((a, b) => b.version - a.version)[0] as
|
|
253
|
+
| OwnerKeyRowsCacheEntry['rows'][number]
|
|
254
|
+
| undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private requireOwner(owner: string) {
|
|
258
|
+
if (!owner || typeof owner !== 'string') {
|
|
259
|
+
throw new Error(`Data key operations require a non-empty owner id; got: ${JSON.stringify(owner)}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* A system Db pinned OUTSIDE any ambient transaction (see class doc). Imported lazily —
|
|
265
|
+
* this module is reachable from the serializer layer, below `Db` in the module graph.
|
|
266
|
+
*/
|
|
267
|
+
private async systemDb() {
|
|
268
|
+
const { Db } = await import('../Db');
|
|
269
|
+
const { getDefaultTransactionContextFactory } = await import('../transaction/TransactionContextFactory');
|
|
270
|
+
const db = new Db(undefined, undefined, undefined, true);
|
|
271
|
+
const factory = getDefaultTransactionContextFactory();
|
|
272
|
+
if (!factory) {
|
|
273
|
+
return db;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Proxy every operation through an empty transaction context so key-table IO
|
|
277
|
+
// auto-commits even when the caller is mid-transaction.
|
|
278
|
+
return new Proxy(db, {
|
|
279
|
+
get(target, prop, receiver) {
|
|
280
|
+
const value = Reflect.get(target, prop, receiver);
|
|
281
|
+
if (typeof value !== 'function') {
|
|
282
|
+
return value;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return (...args: any[]) => factory.runInContext({}, async () => await value.apply(target, args));
|
|
286
|
+
},
|
|
287
|
+
}) as InstanceType<typeof Db>;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private caches(): DataKeyCaches {
|
|
291
|
+
const globalObject = globalThis as any;
|
|
292
|
+
if (!globalObject[DataKeyStore.CACHES_GLOBAL_KEY]) {
|
|
293
|
+
globalObject[DataKeyStore.CACHES_GLOBAL_KEY] = {
|
|
294
|
+
ownerRows: new Map(),
|
|
295
|
+
material: new Map(),
|
|
296
|
+
} as DataKeyCaches;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return globalObject[DataKeyStore.CACHES_GLOBAL_KEY];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private evictOwner(owner: string) {
|
|
303
|
+
this.caches().ownerRows.delete(owner);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Loadable, SourceRepository } from '@proteinjs/reflection';
|
|
2
|
+
import type { Table } from '../Table';
|
|
3
|
+
import { MasterKeyProvider } from './MasterKeyProvider';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A column-encryption configuration problem — a misdeclared column, a missing
|
|
7
|
+
* `DbEncryptionConfigFactory`, an unresolvable key owner. Always a dev-time/boot-time
|
|
8
|
+
* error class, never a data error.
|
|
9
|
+
*/
|
|
10
|
+
export class EncryptedColumnConfigError extends Error {
|
|
11
|
+
constructor(message: string) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'EncryptedColumnConfigError';
|
|
14
|
+
// ES5 down-leveled `extends Error` loses the subclass prototype — restore it so
|
|
15
|
+
// `instanceof` holds for catchers.
|
|
16
|
+
Object.setPrototypeOf(this, EncryptedColumnConfigError.prototype);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Deployment-level configuration for column encryption (`ColumnOptions.encrypted`).
|
|
22
|
+
* Registered once per app through a `DbEncryptionConfigFactory`; every encrypted read,
|
|
23
|
+
* write, and translated query resolves it through `getDbEncryptionConfig`.
|
|
24
|
+
*/
|
|
25
|
+
export interface DbEncryptionConfig {
|
|
26
|
+
/** The vault that wraps/unwraps per-user data keys (see `MasterKeyProvider`). */
|
|
27
|
+
masterKeyProvider: MasterKeyProvider;
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the key-owner id whose data key encrypts a row being written — the row's
|
|
30
|
+
* permission-source scope owner. Return `undefined` to fall through to the framework
|
|
31
|
+
* default (the row's `scope` column value). Consumer layers with richer sharing models
|
|
32
|
+
* (e.g. permission-source trees) supply their scope→owner mapping here; the framework
|
|
33
|
+
* stays generic.
|
|
34
|
+
*/
|
|
35
|
+
resolveKeyOwner?: (args: { table: Table<any>; record: any }) => Promise<string | undefined>;
|
|
36
|
+
/**
|
|
37
|
+
* The key owners whose rows the current caller can read — the caller's own id plus the
|
|
38
|
+
* (bounded) set of owners sharing rows into the caller's view. Search conditions on
|
|
39
|
+
* encrypted columns fingerprint the query once per accessible owner and OR the matches,
|
|
40
|
+
* so this powers shared-scope search. Required for any query that searches an encrypted
|
|
41
|
+
* column; without it such queries are rejected loudly.
|
|
42
|
+
*/
|
|
43
|
+
getAccessibleKeyOwners?: (args: { runAsSystem: boolean }) => Promise<string[]>;
|
|
44
|
+
/**
|
|
45
|
+
* When true, every text-holding column of every registered table MUST declare
|
|
46
|
+
* `encrypted` (`false` or a config object) — registration fails loudly otherwise
|
|
47
|
+
* (see `EncryptedColumns.validateDeclarations`). Ships default-OFF so existing schemas
|
|
48
|
+
* keep booting; the app-wide declaration sweep turns it on.
|
|
49
|
+
*/
|
|
50
|
+
requireEncryptedDeclarations?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Registers the app's `DbEncryptionConfig`. Implement as a Loadable so the framework finds it. */
|
|
54
|
+
export interface DbEncryptionConfigFactory extends Loadable {
|
|
55
|
+
getConfig(): DbEncryptionConfig;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let configOverride: DbEncryptionConfig | undefined;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Set (or clear) the config without a Loadable factory — test harnesses and bootstrap
|
|
62
|
+
* contexts that run outside the source-repository graph.
|
|
63
|
+
*/
|
|
64
|
+
export const setDbEncryptionConfig = (config?: DbEncryptionConfig) => {
|
|
65
|
+
configOverride = config;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** The registered config, or undefined when the deployment has none (a plaintext-only app). */
|
|
69
|
+
export const findDbEncryptionConfig = (): DbEncryptionConfig | undefined => {
|
|
70
|
+
if (configOverride) {
|
|
71
|
+
return configOverride;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const factory = SourceRepository.get().object<DbEncryptionConfigFactory | undefined>(
|
|
75
|
+
'@proteinjs/db/DbEncryptionConfigFactory'
|
|
76
|
+
);
|
|
77
|
+
return factory?.getConfig();
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** The registered config; throws a named error when encryption is used without one. */
|
|
81
|
+
export const getDbEncryptionConfig = (): DbEncryptionConfig => {
|
|
82
|
+
const config = findDbEncryptionConfig();
|
|
83
|
+
if (!config) {
|
|
84
|
+
throw new EncryptedColumnConfigError(
|
|
85
|
+
`A column declares 'encrypted' but no DbEncryptionConfig is registered. Implement ` +
|
|
86
|
+
`@proteinjs/db/DbEncryptionConfigFactory (or call setDbEncryptionConfig) with a ` +
|
|
87
|
+
`masterKeyProvider before using encrypted columns.`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return config;
|
|
92
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A query asked an encrypted column for a shape outside the compatibility contract
|
|
3
|
+
* (`EncryptionQueryTranslator`). Thrown at query-build time, before any statement runs —
|
|
4
|
+
* the message names the limitation and the sanctioned paths, so the limitation cannot be
|
|
5
|
+
* hit silently.
|
|
6
|
+
*/
|
|
7
|
+
export class EncryptedColumnQueryError extends Error {
|
|
8
|
+
constructor(message: string) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'EncryptedColumnQueryError';
|
|
11
|
+
// ES5 down-leveled `extends Error` loses the subclass prototype — restore it so
|
|
12
|
+
// `instanceof` holds for catchers.
|
|
13
|
+
Object.setPrototypeOf(this, EncryptedColumnQueryError.prototype);
|
|
14
|
+
}
|
|
15
|
+
}
|