thatcher 1.0.78 → 1.0.79

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatcher",
3
- "version": "1.0.78",
3
+ "version": "1.0.79",
4
4
  "description": "A config-driven application framework for building data-intensive web apps without code.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -196,7 +196,9 @@ export async function list(entity, where = {}, options = {}) {
196
196
  const lim = options.limit ? parseInt(options.limit, 10) : rows.length;
197
197
  rows = rows.slice(off, off + lim);
198
198
  }
199
- return attachRefDisplays(entity, rows);
199
+ const { decryptFields } = await import('../field-encryption.js');
200
+ const decryptedRows = rows.map(r => decryptFields(r, spec.fields));
201
+ return attachRefDisplays(entity, decryptedRows);
200
202
  }
201
203
 
202
204
  export async function count(entity, where = {}, options = {}) {
@@ -227,7 +229,9 @@ export async function get(entity, id, options = {}) {
227
229
  const { permissionService } = await import('../services/permission.service.js');
228
230
  if (!permissionService.checkRowAccess(options.user, spec, row)) return null;
229
231
  }
230
- const [withDisplay] = await attachRefDisplays(entity, [row]);
232
+ const { decryptFields } = await import('../field-encryption.js');
233
+ const decrypted = decryptFields(row, spec.fields);
234
+ const [withDisplay] = await attachRefDisplays(entity, [decrypted]);
231
235
  return withDisplay;
232
236
  }
233
237
 
@@ -255,15 +259,23 @@ export async function create(entity, data, user) {
255
259
  if (field.auto === 'uuid' && !record[key]) record[key] = genId();
256
260
  if (field.auto === 'timestamp' && !record[key]) record[key] = now();
257
261
  }
262
+ // Encrypt any field marked encrypted:true BEFORE the null/undefined
263
+ // sentinel coercion below, so a real value never reaches the insert as
264
+ // plaintext -- this is the single choke point every create() call passes
265
+ // through, so no caller needs to know the field is encrypted at all.
266
+ const { encryptFields } = await import('../field-encryption.js');
267
+ const encryptedRecord = encryptFields(record, spec.fields);
258
268
  // LanceDB cannot infer a column's type from a null on first insert; coerce any
259
269
  // null/undefined to a typed sentinel ('' for strings) so the insert schema is stable.
260
- for (const k of Object.keys(record)) {
261
- if (record[k] === null || record[k] === undefined) record[k] = '';
270
+ for (const k of Object.keys(encryptedRecord)) {
271
+ if (encryptedRecord[k] === null || encryptedRecord[k] === undefined) encryptedRecord[k] = '';
262
272
  }
263
- unwrap(await client().from(tbl).insert(record), 'create');
273
+ unwrap(await client().from(tbl).insert(encryptedRecord), 'create');
264
274
  // Always return the locally-constructed record: it holds the genId we put in
265
275
  // the TEXT id column. The store's insert() may return a rowid/driver shape, so
266
- // trusting `created` would hand callers the wrong id.
276
+ // trusting `created` would hand callers the wrong id. Return the UNENCRYPTED
277
+ // record -- the caller gets back plaintext, matching what get()/list() will
278
+ // also return after decrypting the stored ciphertext.
267
279
  return record;
268
280
  }
269
281
 
@@ -284,14 +296,19 @@ export async function create(entity, data, user) {
284
296
  // unconditional-write behaviour, unchanged for every existing caller; _version
285
297
  // is added as a new column, ignored by every reader that doesn't ask for it.
286
298
  export async function update(entity, id, data, opts = {}) {
287
- specOf(entity);
299
+ const spec = specOf(entity);
288
300
  const tbl = tableName(entity);
289
301
 
290
302
  const existing = await get(entity, id);
291
303
  if (!existing) throw new Error(`${entity} with id ${id} not found`);
292
304
 
293
305
  const currentVersion = Number(existing._version) || 0;
294
- const patch = { ...data, updated_at: now(), _version: currentVersion + 1 };
306
+ const { encryptFields } = await import('../field-encryption.js');
307
+ // Encrypt only the fields actually present in this partial update -- a
308
+ // caller updating an unrelated field must not force-decrypt/re-encrypt an
309
+ // encrypted field it never touched (encryptFields already skips fields
310
+ // absent from the object, so this is naturally a no-op for those).
311
+ const patch = encryptFields({ ...data, updated_at: now(), _version: currentVersion + 1 }, spec.fields);
295
312
  let builder = client().from(tbl).update(patch).eq('id', id);
296
313
  if (opts.expectedVersion != null) {
297
314
  builder = builder.eq('_version', opts.expectedVersion);
@@ -0,0 +1,85 @@
1
+ import crypto from 'crypto';
2
+
3
+ // AES-256-GCM: authenticated encryption, so a tampered ciphertext fails
4
+ // decryption loudly instead of silently returning corrupted plaintext. A
5
+ // fresh random IV per value (never reused) is required for GCM's security
6
+ // guarantee -- reusing an IV with the same key breaks confidentiality.
7
+ const ALGORITHM = 'aes-256-gcm';
8
+ const IV_LENGTH = 12;
9
+ const ENCRYPTED_PREFIX = 'enc:v1:';
10
+
11
+ let _key = null;
12
+ function getKey() {
13
+ if (_key) return _key;
14
+ const raw = process.env.FIELD_ENCRYPTION_KEY;
15
+ if (!raw) {
16
+ // Fail loud, never silently store plaintext under an encrypted:true field
17
+ // just because the operator forgot to configure a key.
18
+ throw new Error('FIELD_ENCRYPTION_KEY environment variable is not set; cannot encrypt/decrypt a field marked encrypted:true');
19
+ }
20
+ // Accept either a 64-char hex string or any string, hashed to a stable
21
+ // 32-byte key -- never echoed, never logged, this function's return value
22
+ // is the only place the raw key material appears in memory.
23
+ const key = /^[0-9a-fA-F]{64}$/.test(raw) ? Buffer.from(raw, 'hex') : crypto.createHash('sha256').update(raw).digest();
24
+ _key = key;
25
+ return _key;
26
+ }
27
+
28
+ export function encryptValue(plaintext) {
29
+ const key = getKey();
30
+ const iv = crypto.randomBytes(IV_LENGTH);
31
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
32
+ const serialized = typeof plaintext === 'string' ? plaintext : JSON.stringify(plaintext);
33
+ const ciphertext = Buffer.concat([cipher.update(serialized, 'utf8'), cipher.final()]);
34
+ const authTag = cipher.getAuthTag();
35
+ return ENCRYPTED_PREFIX + Buffer.concat([iv, authTag, ciphertext]).toString('base64');
36
+ }
37
+
38
+ export function isEncrypted(value) {
39
+ return typeof value === 'string' && value.startsWith(ENCRYPTED_PREFIX);
40
+ }
41
+
42
+ export function decryptValue(stored) {
43
+ if (!isEncrypted(stored)) return stored;
44
+ const key = getKey();
45
+ const raw = Buffer.from(stored.slice(ENCRYPTED_PREFIX.length), 'base64');
46
+ const iv = raw.subarray(0, IV_LENGTH);
47
+ const authTag = raw.subarray(IV_LENGTH, IV_LENGTH + 16);
48
+ const ciphertext = raw.subarray(IV_LENGTH + 16);
49
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
50
+ decipher.setAuthTag(authTag);
51
+ // setAuthTag + final() throws on any tampering (wrong key, flipped bytes,
52
+ // truncated ciphertext) -- this is the loud-failure guarantee; there is no
53
+ // catch here, a decrypt failure must propagate to the caller as an error.
54
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
55
+ try {
56
+ return JSON.parse(decrypted);
57
+ } catch {
58
+ return decrypted;
59
+ }
60
+ }
61
+
62
+ // Field-def-driven encrypt of every field marked encrypted:true. Fields not
63
+ // present in `record` or not marked are passed through unchanged.
64
+ export function encryptFields(record, specFields) {
65
+ if (!specFields) return record;
66
+ const result = { ...record };
67
+ for (const [key, fieldDef] of Object.entries(specFields)) {
68
+ if (!fieldDef.encrypted) continue;
69
+ if (result[key] === undefined || result[key] === null || result[key] === '') continue;
70
+ if (isEncrypted(result[key])) continue; // already encrypted, don't double-wrap
71
+ result[key] = encryptValue(result[key]);
72
+ }
73
+ return result;
74
+ }
75
+
76
+ export function decryptFields(record, specFields) {
77
+ if (!record || !specFields) return record;
78
+ const result = { ...record };
79
+ for (const [key, fieldDef] of Object.entries(specFields)) {
80
+ if (!fieldDef.encrypted) continue;
81
+ if (result[key] === undefined || result[key] === null) continue;
82
+ result[key] = decryptValue(result[key]);
83
+ }
84
+ return result;
85
+ }