@northstartech/cipher 0.0.0-stage → 1.0.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/README.md +541 -2
- package/dist/client.d.ts +237 -0
- package/dist/client.js +555 -0
- package/dist/client.js.map +1 -0
- package/dist/crypto.d.ts +72 -0
- package/dist/crypto.js +244 -0
- package/dist/crypto.js.map +1 -0
- package/dist/encoding.d.ts +17 -0
- package/dist/encoding.js +79 -0
- package/dist/encoding.js.map +1 -0
- package/dist/errors.d.ts +14 -0
- package/dist/errors.js +22 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/integrations/drizzle.d.ts +96 -0
- package/dist/integrations/drizzle.js +285 -0
- package/dist/integrations/drizzle.js.map +1 -0
- package/dist/integrations/mongoose.d.ts +72 -0
- package/dist/integrations/mongoose.js +582 -0
- package/dist/integrations/mongoose.js.map +1 -0
- package/dist/integrations/prisma.d.ts +65 -0
- package/dist/integrations/prisma.js +588 -0
- package/dist/integrations/prisma.js.map +1 -0
- package/dist/integrations/sequelize.d.ts +62 -0
- package/dist/integrations/sequelize.js +677 -0
- package/dist/integrations/sequelize.js.map +1 -0
- package/dist/integrations/shared.d.ts +268 -0
- package/dist/integrations/shared.js +513 -0
- package/dist/integrations/shared.js.map +1 -0
- package/dist/integrations/typeorm.d.ts +109 -0
- package/dist/integrations/typeorm.js +457 -0
- package/dist/integrations/typeorm.js.map +1 -0
- package/dist/object.d.ts +53 -0
- package/dist/object.js +226 -0
- package/dist/object.js.map +1 -0
- package/dist/search.d.ts +250 -0
- package/dist/search.js +258 -0
- package/dist/search.js.map +1 -0
- package/package.json +101 -4
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sequelize v6 integration: encrypt model attributes with hooks.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import { northstarCipher } from '@northstartech/cipher/sequelize'
|
|
6
|
+
*
|
|
7
|
+
* class Todo extends Model {}
|
|
8
|
+
* Todo.init({ title: DataTypes.TEXT, email: DataTypes.TEXT, email_bidx: DataTypes.STRING(64) }, { sequelize, tableName: 'todos' })
|
|
9
|
+
* northstarCipher(Todo, cipher, { fields: ['title', { name: 'email', searchable: 'email' }] })
|
|
10
|
+
*
|
|
11
|
+
* await Todo.create({ title: 'Buy milk', email: 'ana@example.com' })
|
|
12
|
+
* const rows = await Todo.findAll() // one decrypt request per query
|
|
13
|
+
* const mine = await Todo.findAll({ where: await Todo.cipherWhere('email', 'ANA@example.com') })
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Northstar Search: pass `search: { index: cipher.index('todos'), fields: ['title', 'email', 'done'] }`
|
|
17
|
+
* to keep an index in sync (encrypted attributes are indexed from their stored
|
|
18
|
+
* ciphertext, which the enclave decrypts); `Todo.cipherSearchDocuments(rows)`
|
|
19
|
+
* builds documents for a (re)index.
|
|
20
|
+
*
|
|
21
|
+
* Record ids: explicit/default ids (UUIDV4, …) are used directly; auto-increment
|
|
22
|
+
* keys are reserved from the PostgreSQL sequence before the INSERT; on other
|
|
23
|
+
* dialects a two-phase write inserts the `nsc:pending:v1` sentinel and replaces
|
|
24
|
+
* it with ciphertext in `afterCreate` (inside `options.transaction` when given).
|
|
25
|
+
*/
|
|
26
|
+
import { Op, QueryTypes } from 'sequelize';
|
|
27
|
+
import { CipherError } from '../errors.js';
|
|
28
|
+
import { INDEX_SUFFIX, PENDING_SENTINEL, PlaintextLedger, RESERVE_IDS_SQL, SearchChangeSet, applySearchChange, configError, decryptValues, encryptValues, isCiphertext, normalizeFieldSpec, normalizeSearchSync, plaintextOf, qualifiedTable, recordIdOf, requestError, reservedIdsFrom, searchDocumentOf, searchTokensFor, } from './shared.js';
|
|
29
|
+
export { CIPHERTEXT_PREFIX, PENDING_SENTINEL } from './shared.js';
|
|
30
|
+
const plans = new WeakMap();
|
|
31
|
+
/** Search index changes waiting for their transaction to commit (shared by every synced model). */
|
|
32
|
+
const txChanges = new WeakMap();
|
|
33
|
+
const ledger = new PlaintextLedger();
|
|
34
|
+
const swapped = new WeakSet();
|
|
35
|
+
const bulkPins = new WeakMap();
|
|
36
|
+
const HOOK_NAME = 'northstarCipher';
|
|
37
|
+
const STRING_TYPES = new Set(['STRING', 'TEXT', 'CITEXT', 'CHAR']);
|
|
38
|
+
function planOf(inst) {
|
|
39
|
+
return inst && typeof inst === 'object' ? plans.get(inst.constructor) : undefined;
|
|
40
|
+
}
|
|
41
|
+
function ensureFields(options, attrs) {
|
|
42
|
+
if (!Array.isArray(options.fields))
|
|
43
|
+
return;
|
|
44
|
+
for (const a of attrs)
|
|
45
|
+
if (!options.fields.includes(a))
|
|
46
|
+
options.fields.push(a);
|
|
47
|
+
}
|
|
48
|
+
function coerceId(plan, v) {
|
|
49
|
+
if (!plan.pkInteger)
|
|
50
|
+
return v;
|
|
51
|
+
const n = Number(v);
|
|
52
|
+
return Number.isSafeInteger(n) ? n : v; // BIGINT stays a string, as Sequelize returns it
|
|
53
|
+
}
|
|
54
|
+
async function reserveIds(plan, n, transaction) {
|
|
55
|
+
const rows = await plan.model.sequelize.query(RESERVE_IDS_SQL, {
|
|
56
|
+
bind: [qualifiedTable(plan.table, plan.schema), plan.pkColumn, n],
|
|
57
|
+
type: QueryTypes.SELECT,
|
|
58
|
+
...(transaction ? { transaction } : {}),
|
|
59
|
+
});
|
|
60
|
+
return reservedIdsFrom(rows, n, `${plan.table}.${plan.pkColumn}`).map((v) => coerceId(plan, v));
|
|
61
|
+
}
|
|
62
|
+
function label(plan, f, id) {
|
|
63
|
+
return `${plan.table}.${f.column}${id === undefined || id === null ? '' : ` (${plan.pk}=${String(id)})`}`;
|
|
64
|
+
}
|
|
65
|
+
/** Plaintext an attribute currently stands for (our own ciphertext maps back to its plaintext). */
|
|
66
|
+
function currentPlaintext(inst, attr) {
|
|
67
|
+
return ledger.resolve(inst, attr, inst.dataValues[attr]);
|
|
68
|
+
}
|
|
69
|
+
/** Encrypt `attrs` of each instance (bound to its primary key) in ONE batch and swap in ciphertext. */
|
|
70
|
+
async function encryptInstances(plan, work) {
|
|
71
|
+
const items = [];
|
|
72
|
+
const targets = [];
|
|
73
|
+
for (const { inst, attrs } of work) {
|
|
74
|
+
const id = inst.dataValues[plan.pk];
|
|
75
|
+
for (const f of attrs) {
|
|
76
|
+
const v = currentPlaintext(inst, f.attr);
|
|
77
|
+
if (v === null || v === undefined) {
|
|
78
|
+
inst.dataValues[f.attr] = v ?? null;
|
|
79
|
+
if (f.indexAttr)
|
|
80
|
+
inst.dataValues[f.indexAttr] = null;
|
|
81
|
+
ledger.set(inst, f.attr, null, null);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const lb = label(plan, f, id);
|
|
85
|
+
const plaintext = plaintextOf(v, lb);
|
|
86
|
+
items.push({ collection: plan.table, field: f.column, recordId: recordIdOf(id, lb), plaintext, normalization: f.normalization, label: lb });
|
|
87
|
+
targets.push({ inst, f, plaintext });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const enc = await encryptValues(plan.cipher, items, { keyId: plan.options.keyId });
|
|
91
|
+
enc.forEach((e, i) => {
|
|
92
|
+
const { inst, f, plaintext } = targets[i];
|
|
93
|
+
inst.dataValues[f.attr] = e.ciphertext;
|
|
94
|
+
if (f.indexAttr)
|
|
95
|
+
inst.dataValues[f.indexAttr] = e.index ?? null;
|
|
96
|
+
ledger.set(inst, f.attr, plaintext, e.ciphertext);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/** Two-phase: put the sentinel in place of every non-null encrypted value. */
|
|
100
|
+
function markPending(plan, inst, attrs) {
|
|
101
|
+
for (const f of attrs) {
|
|
102
|
+
const v = currentPlaintext(inst, f.attr);
|
|
103
|
+
if (v === null || v === undefined) {
|
|
104
|
+
inst.dataValues[f.attr] = v ?? null;
|
|
105
|
+
if (f.indexAttr)
|
|
106
|
+
inst.dataValues[f.indexAttr] = null;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const plaintext = plaintextOf(v, label(plan, f));
|
|
110
|
+
inst.dataValues[f.attr] = PENDING_SENTINEL;
|
|
111
|
+
if (f.indexAttr)
|
|
112
|
+
inst.dataValues[f.indexAttr] = null;
|
|
113
|
+
ledger.set(inst, f.attr, plaintext, PENDING_SENTINEL);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Put plaintext back on the instance after a write (or a failed write) and mark it clean. */
|
|
117
|
+
function restore(inst, clean) {
|
|
118
|
+
for (const [attr, e] of ledger.entries(inst)) {
|
|
119
|
+
const cur = inst.dataValues[attr];
|
|
120
|
+
if (e.ciphertext === null || cur === e.ciphertext || cur === undefined) {
|
|
121
|
+
inst.dataValues[attr] = e.plaintext;
|
|
122
|
+
if (clean) {
|
|
123
|
+
inst._previousDataValues[attr] = e.plaintext;
|
|
124
|
+
inst.changed(attr, false);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (clean) {
|
|
129
|
+
const plan = planOf(inst);
|
|
130
|
+
for (const a of plan?.indexAttrs ?? []) {
|
|
131
|
+
inst._previousDataValues[a] = inst.dataValues[a];
|
|
132
|
+
inst.changed(a, false);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
ledger.clear(inst);
|
|
136
|
+
swapped.delete(inst);
|
|
137
|
+
}
|
|
138
|
+
function pendingAttrs(inst) {
|
|
139
|
+
return ledger.entries(inst).filter(([, e]) => e.ciphertext === PENDING_SENTINEL).map(([a]) => a);
|
|
140
|
+
}
|
|
141
|
+
/** Second phase of a two-phase insert: encrypt with the real id and replace the sentinel (same transaction). */
|
|
142
|
+
async function completeTwoPhase(plan, insts, transaction) {
|
|
143
|
+
const work = insts.map((inst) => ({ inst, attrs: pendingAttrs(inst).map((a) => plan.byAttr.get(a)) })).filter((w) => w.attrs.length);
|
|
144
|
+
if (!work.length)
|
|
145
|
+
return;
|
|
146
|
+
const qi = plan.model.sequelize.getQueryInterface();
|
|
147
|
+
const tableName = plan.model.getTableName();
|
|
148
|
+
try {
|
|
149
|
+
// The ledger maps the sentinel back to its plaintext, so this encrypts the real values with the real ids.
|
|
150
|
+
await encryptInstances(plan, work);
|
|
151
|
+
for (const { inst, attrs } of work) {
|
|
152
|
+
const values = {};
|
|
153
|
+
const where = { [plan.pkColumn]: inst.dataValues[plan.pk] };
|
|
154
|
+
for (const f of attrs) {
|
|
155
|
+
values[f.column] = inst.dataValues[f.attr];
|
|
156
|
+
if (f.indexAttr && f.indexColumn)
|
|
157
|
+
values[f.indexColumn] = inst.dataValues[f.indexAttr];
|
|
158
|
+
where[f.column] = PENDING_SENTINEL;
|
|
159
|
+
}
|
|
160
|
+
const res = (await qi.bulkUpdate(tableName, values, where, { ...(transaction ? { transaction } : {}) }));
|
|
161
|
+
const affected = typeof res === 'number' ? res : Array.isArray(res) ? Number(res[1] ?? res[0]) : NaN;
|
|
162
|
+
if (affected !== 1)
|
|
163
|
+
throw new CipherError('PROTOCOL_ERROR', `${plan.table}: two-phase write could not find the pending row ${plan.pk}=${String(inst.dataValues[plan.pk])}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
// Fail closed: drop the sentinel rows we inserted (best effort; a transaction rollback also removes them).
|
|
168
|
+
const ids = work.map((w) => w.inst.dataValues[plan.pk]).filter((x) => x !== null && x !== undefined);
|
|
169
|
+
if (ids.length) {
|
|
170
|
+
await qi.bulkDelete(tableName, { [plan.pkColumn]: { [Op.in]: ids } }, { ...(transaction ? { transaction } : {}) }).catch(() => undefined);
|
|
171
|
+
}
|
|
172
|
+
for (const w of work)
|
|
173
|
+
restore(w.inst, false);
|
|
174
|
+
throw e;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** Refuse plaintext in writes that skip hooks (`hooks: false`): mirrors the `LIKE 'nsc:%'` CHECK constraint. */
|
|
178
|
+
function assertNoPlaintext(plan, values, what) {
|
|
179
|
+
for (const f of plan.fields) {
|
|
180
|
+
const v = values[f.attr];
|
|
181
|
+
if (v !== undefined && v !== null && !(typeof v === 'string' && (isCiphertext(v) || v === PENDING_SENTINEL))) {
|
|
182
|
+
throw requestError(`${plan.table}.${f.column}: ${what} with hooks disabled would store plaintext in an encrypted column`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** Filters on ciphertext never match: refuse them (null checks are fine). */
|
|
187
|
+
function assertWhere(plan, where) {
|
|
188
|
+
if (!where || typeof where !== 'object' || Array.isArray(where)) {
|
|
189
|
+
if (Array.isArray(where))
|
|
190
|
+
for (const w of where)
|
|
191
|
+
assertWhere(plan, w);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const w = where;
|
|
195
|
+
for (const k of Object.keys(w)) {
|
|
196
|
+
const f = plan.byAttr.get(k) ?? plan.fields.find((x) => x.column === k);
|
|
197
|
+
if (!f)
|
|
198
|
+
continue;
|
|
199
|
+
const v = w[k];
|
|
200
|
+
const nullOnly = v === null ||
|
|
201
|
+
(v !== undefined &&
|
|
202
|
+
typeof v === 'object' &&
|
|
203
|
+
!Array.isArray(v) &&
|
|
204
|
+
Object.keys(v).length === 0 &&
|
|
205
|
+
Object.getOwnPropertySymbols(v).every((s) => (s === Op.is || s === Op.not || s === Op.ne || s === Op.eq) && v[s] === null));
|
|
206
|
+
if (!nullOnly) {
|
|
207
|
+
throw requestError(`${plan.table}.${f.column} is encrypted: only null checks can filter it` + (f.indexAttr ? ` (use ${plan.model.name}.cipherWhere('${f.attr}', value))` : ''));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
for (const s of Object.getOwnPropertySymbols(w)) {
|
|
211
|
+
if (s === Op.and || s === Op.or || s === Op.not) {
|
|
212
|
+
const v = w[s];
|
|
213
|
+
for (const x of Array.isArray(v) ? v : [v])
|
|
214
|
+
assertWhere(plan, x);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/** Collect instances (and eagerly loaded associations) produced by one query. */
|
|
219
|
+
function collect(value, out) {
|
|
220
|
+
if (Array.isArray(value)) {
|
|
221
|
+
for (const v of value)
|
|
222
|
+
collect(v, out);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (!value || typeof value !== 'object' || !('dataValues' in value))
|
|
226
|
+
return;
|
|
227
|
+
const inst = value;
|
|
228
|
+
if (out.has(inst))
|
|
229
|
+
return;
|
|
230
|
+
out.add(inst);
|
|
231
|
+
for (const v of Object.values(inst.dataValues))
|
|
232
|
+
if (v && typeof v === 'object')
|
|
233
|
+
collect(v, out);
|
|
234
|
+
}
|
|
235
|
+
async function decryptFound(plan, result, options) {
|
|
236
|
+
const raw = options.raw === true;
|
|
237
|
+
const insts = new Set();
|
|
238
|
+
const rows = [];
|
|
239
|
+
if (raw) {
|
|
240
|
+
for (const r of Array.isArray(result) ? result : [result])
|
|
241
|
+
if (r && typeof r === 'object')
|
|
242
|
+
rows.push(r);
|
|
243
|
+
}
|
|
244
|
+
else
|
|
245
|
+
collect(result, insts);
|
|
246
|
+
const items = [];
|
|
247
|
+
const targets = [];
|
|
248
|
+
const add = (p, obj, prev) => {
|
|
249
|
+
for (const f of p.fields) {
|
|
250
|
+
if (!(f.attr in obj))
|
|
251
|
+
continue;
|
|
252
|
+
const stored = obj[f.attr];
|
|
253
|
+
const lb = label(p, f, obj[p.pk]);
|
|
254
|
+
if (stored !== null && stored !== undefined && (obj[p.pk] === undefined || obj[p.pk] === null)) {
|
|
255
|
+
throw requestError(`${lb}: select the primary key "${p.pk}" together with encrypted attributes`);
|
|
256
|
+
}
|
|
257
|
+
items.push({ collection: p.table, field: f.column, recordId: stored === null || stored === undefined ? '' : recordIdOf(obj[p.pk], lb), stored, label: lb });
|
|
258
|
+
targets.push({ obj, prev, attr: f.attr });
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
for (const r of rows)
|
|
262
|
+
add(plan, r);
|
|
263
|
+
for (const inst of insts) {
|
|
264
|
+
const p = planOf(inst);
|
|
265
|
+
if (p)
|
|
266
|
+
add(p, inst.dataValues, inst._previousDataValues);
|
|
267
|
+
}
|
|
268
|
+
const plain = await decryptValues(plan.cipher, items, { purpose: plan.options.purpose, readLegacyPlaintext: plan.options.readLegacyPlaintext === true });
|
|
269
|
+
plain.forEach((p, i) => {
|
|
270
|
+
const t = targets[i];
|
|
271
|
+
t.obj[t.attr] = p;
|
|
272
|
+
if (t.prev)
|
|
273
|
+
t.prev[t.attr] = p;
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
function parseFields(model, spec) {
|
|
277
|
+
if (Array.isArray(spec)) {
|
|
278
|
+
return spec.map((s) => {
|
|
279
|
+
if (typeof s === 'string')
|
|
280
|
+
return [s, true];
|
|
281
|
+
if (!s || typeof s !== 'object' || typeof s.name !== 'string')
|
|
282
|
+
throw configError(`Sequelize: ${model.name}: field entries are names or { name, searchable? }`);
|
|
283
|
+
const { name, ...rest } = s;
|
|
284
|
+
return [name, Object.keys(rest).length ? rest : true];
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
if (spec && typeof spec === 'object')
|
|
288
|
+
return Object.entries(spec);
|
|
289
|
+
throw configError(`Sequelize: ${model.name}: options.fields is required`);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Encrypt attributes of a Sequelize model. Call once per model, after `init`/`define`
|
|
293
|
+
* and before use. Returns the model, extended with `cipherWhere`.
|
|
294
|
+
*/
|
|
295
|
+
export function northstarCipher(model, cipher, options) {
|
|
296
|
+
if (!model || typeof model.getAttributes !== 'function' || !model.sequelize)
|
|
297
|
+
throw configError('Sequelize: northstarCipher(Model, …) needs an initialized model');
|
|
298
|
+
if (!cipher || typeof cipher.encryptManySettled !== 'function')
|
|
299
|
+
throw configError('Sequelize: a NorthstarCipher client is required');
|
|
300
|
+
if (plans.has(model))
|
|
301
|
+
throw configError(`Sequelize: ${model.name} is already configured for Northstar Cipher`);
|
|
302
|
+
const attrs = model.getAttributes();
|
|
303
|
+
const pks = model.primaryKeyAttributes;
|
|
304
|
+
if (pks.length !== 1)
|
|
305
|
+
throw configError(`Sequelize: ${model.name} must have exactly one primary key to bind ciphertext to records`);
|
|
306
|
+
const pk = pks[0];
|
|
307
|
+
const pkAttr = attrs[pk];
|
|
308
|
+
const pkType = String(pkAttr.type?.key ?? '');
|
|
309
|
+
const pkInteger = ['INTEGER', 'BIGINT', 'SMALLINT', 'MEDIUMINT', 'TINYINT'].includes(pkType);
|
|
310
|
+
const dialect = model.sequelize.getDialect();
|
|
311
|
+
const tableName = model.getTableName();
|
|
312
|
+
const table = typeof tableName === 'string' ? tableName : String(tableName.tableName);
|
|
313
|
+
const schema = typeof tableName === 'object' && tableName ? tableName.schema : undefined;
|
|
314
|
+
const fields = [];
|
|
315
|
+
for (const [attr, spec] of parseFields(model, options.fields)) {
|
|
316
|
+
const lb = `Sequelize: ${model.name}.${attr}`;
|
|
317
|
+
const a = attrs[attr];
|
|
318
|
+
if (!a)
|
|
319
|
+
throw configError(`${lb}: no such attribute`);
|
|
320
|
+
if (attr === pk)
|
|
321
|
+
throw configError(`${lb}: the primary key cannot be encrypted`);
|
|
322
|
+
if (!STRING_TYPES.has(String(a.type?.key ?? '')))
|
|
323
|
+
throw configError(`${lb}: encrypted attributes must be TEXT (the column stores nsc:v1:… text)`);
|
|
324
|
+
const n = normalizeFieldSpec(spec, lb);
|
|
325
|
+
let indexAttr;
|
|
326
|
+
if (n.searchable) {
|
|
327
|
+
indexAttr = n.indexField ?? [`${attr}${INDEX_SUFFIX}`, `${attr}Bidx`].find((k) => attrs[k]);
|
|
328
|
+
if (!indexAttr || !attrs[indexAttr])
|
|
329
|
+
throw configError(`${lb}: searchable attributes need a blind-index attribute (add ${attr}${INDEX_SUFFIX}: DataTypes.STRING(64) with an index, or set indexField)`);
|
|
330
|
+
}
|
|
331
|
+
fields.push({
|
|
332
|
+
attr,
|
|
333
|
+
column: a.field ?? attr,
|
|
334
|
+
...(n.normalization ? { normalization: n.normalization } : {}),
|
|
335
|
+
...(indexAttr ? { indexAttr, indexColumn: attrs[indexAttr].field ?? indexAttr } : {}),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
if (!fields.length)
|
|
339
|
+
throw configError(`Sequelize: ${model.name}: no encrypted attributes listed`);
|
|
340
|
+
const plan = {
|
|
341
|
+
model,
|
|
342
|
+
table,
|
|
343
|
+
schema,
|
|
344
|
+
pk,
|
|
345
|
+
pkColumn: pkAttr.field ?? pk,
|
|
346
|
+
pkInteger,
|
|
347
|
+
strategy: pkAttr.autoIncrement ? (dialect === 'postgres' ? 'sequence' : 'two-phase') : 'explicit',
|
|
348
|
+
fields,
|
|
349
|
+
byAttr: new Map(fields.map((f) => [f.attr, f])),
|
|
350
|
+
indexAttrs: new Set(fields.flatMap((f) => (f.indexAttr ? [f.indexAttr] : []))),
|
|
351
|
+
cipher,
|
|
352
|
+
options,
|
|
353
|
+
};
|
|
354
|
+
plans.set(model, plan);
|
|
355
|
+
const allAttrs = (f) => f.flatMap((x) => (x.indexAttr ? [x.attr, x.indexAttr] : [x.attr]));
|
|
356
|
+
// ------------------------------------------------------- Northstar Search
|
|
357
|
+
const searchPlans = (names) => names.map((attr) => {
|
|
358
|
+
if (!attrs[attr])
|
|
359
|
+
throw configError(`Sequelize: ${model.name}.${attr}: no such attribute (search fields)`);
|
|
360
|
+
const f = plan.byAttr.get(attr);
|
|
361
|
+
return { attr, ...(f ? { encrypted: { collection: table, field: f.column } } : {}) };
|
|
362
|
+
});
|
|
363
|
+
const sync = options.search ? normalizeSearchSync(options.search, `Sequelize: ${model.name}`) : undefined;
|
|
364
|
+
const syncPlans = sync ? searchPlans(sync.fields) : undefined;
|
|
365
|
+
const docOf = (record, fp) => {
|
|
366
|
+
const values = record && typeof record === 'object' && 'dataValues' in record ? record.dataValues : (record ?? {});
|
|
367
|
+
return searchDocumentOf(values, { id: values[pk], fields: fp, label: table });
|
|
368
|
+
};
|
|
369
|
+
/** Stored documents of rows by primary key (`hooks: false`: ciphertext stays as stored). */
|
|
370
|
+
const storedDocs = async (ids, opts) => {
|
|
371
|
+
if (!syncPlans || !ids.length)
|
|
372
|
+
return [];
|
|
373
|
+
// `hooks: false` (honoured by findAll, missing from its type) skips afterFind: no decryption.
|
|
374
|
+
const rows = await model.findAll({ where: { [pk]: { [Op.in]: ids } }, hooks: false, ...(opts.transaction ? { transaction: opts.transaction } : {}) });
|
|
375
|
+
return rows.map((r) => docOf(r, syncPlans));
|
|
376
|
+
};
|
|
377
|
+
const idsWhere = async (opts) => {
|
|
378
|
+
const rows = await model.findAll({
|
|
379
|
+
attributes: [pk],
|
|
380
|
+
where: opts.where,
|
|
381
|
+
hooks: false,
|
|
382
|
+
raw: true,
|
|
383
|
+
...(opts.limit ? { limit: opts.limit } : {}),
|
|
384
|
+
...(opts.force ? { paranoid: false } : {}),
|
|
385
|
+
...(opts.transaction ? { transaction: opts.transaction } : {}),
|
|
386
|
+
});
|
|
387
|
+
return rows.map((r) => r[pk]);
|
|
388
|
+
};
|
|
389
|
+
/** Apply an index change now, or (inside a transaction) once the outermost transaction commits. */
|
|
390
|
+
const syncChange = async (opts, change) => {
|
|
391
|
+
if (!sync || (!change.upsert?.length && !change.delete?.length))
|
|
392
|
+
return;
|
|
393
|
+
let t = opts?.transaction;
|
|
394
|
+
while (t?.parent)
|
|
395
|
+
t = t.parent;
|
|
396
|
+
if (!t || typeof t.afterCommit !== 'function')
|
|
397
|
+
return applySearchChange(sync, change);
|
|
398
|
+
let set = txChanges.get(t);
|
|
399
|
+
if (!set) {
|
|
400
|
+
txChanges.set(t, (set = new SearchChangeSet()));
|
|
401
|
+
const pending = set;
|
|
402
|
+
t.afterCommit(() => pending.apply(true));
|
|
403
|
+
}
|
|
404
|
+
set.add(sync, change);
|
|
405
|
+
};
|
|
406
|
+
const bulkIds = new WeakMap();
|
|
407
|
+
const add = (name, fn) => model.addHook(name, HOOK_NAME, fn);
|
|
408
|
+
add('beforeCreate', (async (inst, opts) => {
|
|
409
|
+
const touched = fields.filter((f) => inst.dataValues[f.attr] !== undefined);
|
|
410
|
+
if (!touched.length)
|
|
411
|
+
return;
|
|
412
|
+
if (inst.dataValues[pk] === undefined || inst.dataValues[pk] === null) {
|
|
413
|
+
if (plan.strategy === 'sequence')
|
|
414
|
+
inst.dataValues[pk] = (await reserveIds(plan, 1, opts.transaction))[0];
|
|
415
|
+
else if (plan.strategy === 'two-phase') {
|
|
416
|
+
markPending(plan, inst, touched);
|
|
417
|
+
ensureFields(opts, allAttrs(touched));
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
else
|
|
421
|
+
throw requestError(`${table}.${plan.pkColumn} must be set before writing encrypted attributes`);
|
|
422
|
+
}
|
|
423
|
+
await encryptInstances(plan, [{ inst, attrs: touched }]);
|
|
424
|
+
ensureFields(opts, [pk, ...allAttrs(touched)]);
|
|
425
|
+
}));
|
|
426
|
+
add('afterCreate', (async (inst, opts) => {
|
|
427
|
+
if (pendingAttrs(inst).length)
|
|
428
|
+
await completeTwoPhase(plan, [inst], opts.transaction);
|
|
429
|
+
const doc = syncPlans ? docOf(inst, syncPlans) : undefined; // before restore: encrypted attributes still hold ciphertext
|
|
430
|
+
restore(inst, true);
|
|
431
|
+
if (doc)
|
|
432
|
+
await syncChange(opts, { upsert: [doc] });
|
|
433
|
+
}));
|
|
434
|
+
add('beforeUpdate', (async (inst, opts) => {
|
|
435
|
+
if (inst.changed(pk) && inst._previousDataValues[pk] !== undefined) {
|
|
436
|
+
throw requestError(`${table}.${plan.pkColumn}: changing the primary key would invalidate the record's ciphertext`);
|
|
437
|
+
}
|
|
438
|
+
const touched = fields.filter((f) => inst.changed(f.attr));
|
|
439
|
+
if (!touched.length)
|
|
440
|
+
return;
|
|
441
|
+
await encryptInstances(plan, [{ inst, attrs: touched }]);
|
|
442
|
+
ensureFields(opts, allAttrs(touched));
|
|
443
|
+
swapped.delete(inst);
|
|
444
|
+
// Model.update(..., { individualHooks }) may fall back to one UPDATE for all rows: pin it to the ids we encrypted for.
|
|
445
|
+
if (opts.individualHooks && opts.where !== undefined) {
|
|
446
|
+
let pin = bulkPins.get(opts);
|
|
447
|
+
if (!pin)
|
|
448
|
+
bulkPins.set(opts, (pin = { where: opts.where, ids: new Set() }));
|
|
449
|
+
pin.ids.add(inst.dataValues[pk]);
|
|
450
|
+
opts.where = { [Op.and]: [pin.where, { [pk]: { [Op.in]: [...pin.ids] } }] };
|
|
451
|
+
}
|
|
452
|
+
}));
|
|
453
|
+
add('afterUpdate', (async (inst, opts) => {
|
|
454
|
+
const doc = syncPlans && sync.fields.some((a) => inst.changed(a)) ? docOf(inst, syncPlans) : undefined;
|
|
455
|
+
restore(inst, true);
|
|
456
|
+
if (doc)
|
|
457
|
+
await syncChange(opts, { upsert: [doc] });
|
|
458
|
+
}));
|
|
459
|
+
if (sync) {
|
|
460
|
+
add('afterDestroy', (async (inst, opts) => {
|
|
461
|
+
await syncChange(opts, { delete: [recordIdOf(inst.dataValues[pk], table)] });
|
|
462
|
+
}));
|
|
463
|
+
// Model.destroy()/update() without per-instance hooks: note the affected ids first.
|
|
464
|
+
add('beforeBulkDestroy', (async (opts) => {
|
|
465
|
+
if (!opts.individualHooks)
|
|
466
|
+
bulkIds.set(opts, await idsWhere(opts));
|
|
467
|
+
}));
|
|
468
|
+
add('afterBulkDestroy', (async (opts) => {
|
|
469
|
+
const ids = bulkIds.get(opts);
|
|
470
|
+
bulkIds.delete(opts);
|
|
471
|
+
if (ids?.length)
|
|
472
|
+
await syncChange(opts, { delete: ids.map((id) => recordIdOf(id, table)) });
|
|
473
|
+
}));
|
|
474
|
+
add('afterBulkUpdate', (async (opts) => {
|
|
475
|
+
const ids = bulkIds.get(opts);
|
|
476
|
+
bulkIds.delete(opts);
|
|
477
|
+
if (ids?.length)
|
|
478
|
+
await syncChange(opts, { upsert: await storedDocs(ids, opts) });
|
|
479
|
+
}));
|
|
480
|
+
}
|
|
481
|
+
// Sequelize re-validates attributes changed by before* hooks: validate the plaintext, not our ciphertext.
|
|
482
|
+
add('beforeValidate', ((inst) => {
|
|
483
|
+
if (!ledger.has(inst))
|
|
484
|
+
return;
|
|
485
|
+
for (const [attr, e] of ledger.entries(inst))
|
|
486
|
+
if (e.ciphertext !== null && inst.dataValues[attr] === e.ciphertext)
|
|
487
|
+
inst.dataValues[attr] = e.plaintext;
|
|
488
|
+
swapped.add(inst);
|
|
489
|
+
}));
|
|
490
|
+
add('afterValidate', ((inst) => {
|
|
491
|
+
if (!swapped.has(inst))
|
|
492
|
+
return;
|
|
493
|
+
for (const [attr, e] of ledger.entries(inst))
|
|
494
|
+
if (e.ciphertext !== null && inst.dataValues[attr] === e.plaintext)
|
|
495
|
+
inst.dataValues[attr] = e.ciphertext;
|
|
496
|
+
swapped.delete(inst);
|
|
497
|
+
}));
|
|
498
|
+
add('validationFailed', ((inst) => {
|
|
499
|
+
if (swapped.has(inst))
|
|
500
|
+
restore(inst, false);
|
|
501
|
+
}));
|
|
502
|
+
add('beforeBulkCreate', (async (insts, opts) => {
|
|
503
|
+
if (opts.individualHooks)
|
|
504
|
+
return; // each instance goes through beforeCreate
|
|
505
|
+
const work = insts.map((inst) => ({ inst, attrs: fields.filter((f) => inst.dataValues[f.attr] !== undefined) })).filter((w) => w.attrs.length);
|
|
506
|
+
if (!work.length)
|
|
507
|
+
return;
|
|
508
|
+
const upd = opts.updateOnDuplicate;
|
|
509
|
+
if (Array.isArray(upd) && upd.some((a) => plan.byAttr.has(a) || plan.indexAttrs.has(a))) {
|
|
510
|
+
throw requestError(`${table}: bulkCreate updateOnDuplicate cannot target encrypted attributes (conflicting rows keep their own ids)`);
|
|
511
|
+
}
|
|
512
|
+
if (opts.validate) {
|
|
513
|
+
// Validate plaintext now; Sequelize would otherwise validate our ciphertext after this hook.
|
|
514
|
+
const S = model.sequelize.constructor;
|
|
515
|
+
const errors = [];
|
|
516
|
+
await Promise.all(insts.map(async (inst) => {
|
|
517
|
+
try {
|
|
518
|
+
await inst.validate({ ...opts, hooks: false });
|
|
519
|
+
}
|
|
520
|
+
catch (err) {
|
|
521
|
+
errors.push(new S.BulkRecordError(err, inst));
|
|
522
|
+
}
|
|
523
|
+
}));
|
|
524
|
+
if (errors.length)
|
|
525
|
+
throw new S.AggregateError(errors);
|
|
526
|
+
opts.validate = false;
|
|
527
|
+
}
|
|
528
|
+
const missing = work.filter((w) => w.inst.dataValues[pk] === undefined || w.inst.dataValues[pk] === null);
|
|
529
|
+
if (missing.length) {
|
|
530
|
+
if (plan.strategy === 'sequence') {
|
|
531
|
+
const ids = await reserveIds(plan, missing.length, opts.transaction);
|
|
532
|
+
missing.forEach((w, i) => (w.inst.dataValues[pk] = ids[i]));
|
|
533
|
+
ensureFields(opts, [pk]);
|
|
534
|
+
}
|
|
535
|
+
else if (plan.strategy === 'two-phase') {
|
|
536
|
+
if (opts.ignoreDuplicates)
|
|
537
|
+
throw requestError(`${table}: bulkCreate ignoreDuplicates cannot be combined with the two-phase write (generated ids cannot be matched to rows)`);
|
|
538
|
+
for (const w of missing)
|
|
539
|
+
markPending(plan, w.inst, w.attrs);
|
|
540
|
+
}
|
|
541
|
+
else
|
|
542
|
+
throw requestError(`${table}.${plan.pkColumn} must be set before writing encrypted attributes`);
|
|
543
|
+
}
|
|
544
|
+
const ready = work.filter((w) => !pendingAttrs(w.inst).length);
|
|
545
|
+
await encryptInstances(plan, ready);
|
|
546
|
+
ensureFields(opts, allAttrs(fields));
|
|
547
|
+
}));
|
|
548
|
+
add('afterBulkCreate', (async (insts, opts) => {
|
|
549
|
+
if (opts.individualHooks)
|
|
550
|
+
return;
|
|
551
|
+
const pending = insts.filter((i) => pendingAttrs(i).length);
|
|
552
|
+
if (pending.length)
|
|
553
|
+
await completeTwoPhase(plan, pending, opts.transaction);
|
|
554
|
+
// Rows skipped by ignoreDuplicates come back without an id: nothing to index.
|
|
555
|
+
const docs = syncPlans ? insts.filter((i) => i.dataValues[pk] !== undefined && i.dataValues[pk] !== null).map((i) => docOf(i, syncPlans)) : [];
|
|
556
|
+
for (const inst of insts)
|
|
557
|
+
restore(inst, true);
|
|
558
|
+
if (docs.length)
|
|
559
|
+
await syncChange(opts, { upsert: docs });
|
|
560
|
+
}));
|
|
561
|
+
add('beforeBulkUpdate', ((opts) => {
|
|
562
|
+
const values = opts.attributes ?? {};
|
|
563
|
+
assertWhere(plan, opts.where);
|
|
564
|
+
if (values[pk] !== undefined)
|
|
565
|
+
throw requestError(`${table}.${plan.pkColumn}: changing the primary key would invalidate the record's ciphertext`);
|
|
566
|
+
for (const f of fields) {
|
|
567
|
+
if (f.indexAttr && values[f.indexAttr] !== undefined && values[f.attr] === undefined) {
|
|
568
|
+
throw requestError(`${table}.${f.indexAttr} is the blind index of ${f.attr} and is managed by Northstar Cipher`);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (fields.some((f) => values[f.attr] !== undefined)) {
|
|
572
|
+
// Every record needs its own ciphertext: run per-instance hooks (Sequelize then updates row by row).
|
|
573
|
+
opts.individualHooks = true;
|
|
574
|
+
ensureFields(opts, allAttrs(fields.filter((f) => values[f.attr] !== undefined)));
|
|
575
|
+
}
|
|
576
|
+
}));
|
|
577
|
+
if (sync) {
|
|
578
|
+
add('beforeBulkUpdate', (async (opts) => {
|
|
579
|
+
const values = opts.attributes ?? {};
|
|
580
|
+
if (!opts.individualHooks && sync.fields.some((a) => values[a] !== undefined))
|
|
581
|
+
bulkIds.set(opts, await idsWhere(opts));
|
|
582
|
+
}));
|
|
583
|
+
}
|
|
584
|
+
add('beforeUpsert', ((values) => {
|
|
585
|
+
if (fields.some((f) => values[f.attr] !== undefined)) {
|
|
586
|
+
throw requestError(`${table}: Model.upsert() cannot encrypt (Sequelize builds the statement before hooks run); use findOrCreate()/save() instead`);
|
|
587
|
+
}
|
|
588
|
+
}));
|
|
589
|
+
for (const h of ['beforeFind', 'beforeCount', 'beforeBulkDestroy']) {
|
|
590
|
+
add(h, ((opts) => {
|
|
591
|
+
assertWhere(plan, opts?.where);
|
|
592
|
+
// Ordering by ciphertext is meaningless: refuse `order: [['title', 'ASC']]` / `order: ['title']`.
|
|
593
|
+
for (const o of Array.isArray(opts?.order) ? opts.order : opts?.order ? [opts.order] : []) {
|
|
594
|
+
const name = typeof o === 'string' ? o : Array.isArray(o) && typeof o[0] === 'string' ? o[0] : undefined;
|
|
595
|
+
if (name !== undefined && (plan.byAttr.has(name) || fields.some((f) => f.column === name)))
|
|
596
|
+
throw requestError(`${table}.${name} is encrypted: ordering by it is not supported`);
|
|
597
|
+
}
|
|
598
|
+
}));
|
|
599
|
+
}
|
|
600
|
+
add('afterFind', (async (result, opts) => {
|
|
601
|
+
if (result === null || result === undefined)
|
|
602
|
+
return;
|
|
603
|
+
await decryptFound(plan, result, opts ?? {});
|
|
604
|
+
}));
|
|
605
|
+
// Hooks are bypassed with `hooks: false`: refuse plaintext there, and restore plaintext after failed saves.
|
|
606
|
+
const proto = model.prototype;
|
|
607
|
+
const prevSave = proto.save;
|
|
608
|
+
Object.defineProperty(proto, 'save', {
|
|
609
|
+
configurable: true,
|
|
610
|
+
writable: true,
|
|
611
|
+
value: async function save(o) {
|
|
612
|
+
if (o?.hooks === false) {
|
|
613
|
+
const changed = {};
|
|
614
|
+
for (const f of fields)
|
|
615
|
+
if (this.isNewRecord || this.changed(f.attr))
|
|
616
|
+
changed[f.attr] = this.dataValues[f.attr];
|
|
617
|
+
assertNoPlaintext(plan, changed, 'save()');
|
|
618
|
+
}
|
|
619
|
+
try {
|
|
620
|
+
return await prevSave.call(this, o);
|
|
621
|
+
}
|
|
622
|
+
catch (e) {
|
|
623
|
+
if (ledger.has(this))
|
|
624
|
+
restore(this, false);
|
|
625
|
+
throw e;
|
|
626
|
+
}
|
|
627
|
+
},
|
|
628
|
+
});
|
|
629
|
+
const statics = model;
|
|
630
|
+
const prevBulkCreate = statics.bulkCreate;
|
|
631
|
+
const prevUpdate = statics.update;
|
|
632
|
+
Object.defineProperty(model, 'bulkCreate', {
|
|
633
|
+
configurable: true,
|
|
634
|
+
writable: true,
|
|
635
|
+
value: async function bulkCreate(records, o) {
|
|
636
|
+
if (o?.hooks === false)
|
|
637
|
+
for (const r of records ?? [])
|
|
638
|
+
assertNoPlaintext(plan, r && typeof r === 'object' ? r : {}, 'bulkCreate()');
|
|
639
|
+
return prevBulkCreate.call(this, records, o);
|
|
640
|
+
},
|
|
641
|
+
});
|
|
642
|
+
Object.defineProperty(model, 'update', {
|
|
643
|
+
configurable: true,
|
|
644
|
+
writable: true,
|
|
645
|
+
value: async function update(values, o) {
|
|
646
|
+
if (o?.hooks === false)
|
|
647
|
+
assertNoPlaintext(plan, values ?? {}, 'update()');
|
|
648
|
+
return prevUpdate.call(this, values, o);
|
|
649
|
+
},
|
|
650
|
+
});
|
|
651
|
+
Object.defineProperty(model, 'cipherSearchDocuments', {
|
|
652
|
+
configurable: true,
|
|
653
|
+
writable: true,
|
|
654
|
+
value: function cipherSearchDocuments(records, names) {
|
|
655
|
+
const fp = names ? searchPlans(names) : syncPlans;
|
|
656
|
+
if (!fp)
|
|
657
|
+
throw configError(`Sequelize: ${model.name}.cipherSearchDocuments(records, fields): pass the fields to index (or configure search)`);
|
|
658
|
+
return records.map((r) => docOf(r, fp));
|
|
659
|
+
},
|
|
660
|
+
});
|
|
661
|
+
Object.defineProperty(model, 'cipherWhere', {
|
|
662
|
+
configurable: true,
|
|
663
|
+
writable: true,
|
|
664
|
+
value: async function cipherWhere(field, value) {
|
|
665
|
+
const f = plan.byAttr.get(field);
|
|
666
|
+
if (!f)
|
|
667
|
+
throw configError(`Sequelize: ${model.name}.${field} is not an encrypted attribute`);
|
|
668
|
+
if (!f.indexAttr)
|
|
669
|
+
throw configError(`Sequelize: ${model.name}.${field} is not searchable (set searchable)`);
|
|
670
|
+
const tokens = await searchTokensFor(cipher, { collection: table, field: f.column, normalization: f.normalization, values: value, keyId: options.keyId, label: `${table}.${f.column}` });
|
|
671
|
+
return { [f.indexAttr]: { [Op.in]: tokens } };
|
|
672
|
+
},
|
|
673
|
+
});
|
|
674
|
+
return model;
|
|
675
|
+
}
|
|
676
|
+
export { CipherError };
|
|
677
|
+
//# sourceMappingURL=sequelize.js.map
|