@protobase/server 0.33.4 → 0.34.1
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/dist/assistant/query-tools.js +1 -1
- package/dist/auth/jwt.d.ts +15 -1
- package/dist/auth/jwt.js +41 -5
- package/dist/better-auth/authenticator.d.ts +16 -1
- package/dist/better-auth/authenticator.js +41 -24
- package/dist/better-auth/create-auth.d.ts +3063 -363
- package/dist/better-auth/create-auth.js +39 -20
- package/dist/better-auth/mcp/connected-apps.d.ts +13 -0
- package/dist/better-auth/mcp/connected-apps.js +86 -0
- package/dist/better-auth/mcp/consent-check.js +38 -0
- package/dist/better-auth/mcp/consent-reference.js +16 -0
- package/dist/better-auth/mcp/discovery-routes.js +19 -0
- package/dist/better-auth/mcp/grant-reference.js +25 -0
- package/dist/better-auth/mcp/mcp-plugins.d.ts +13 -0
- package/dist/better-auth/mcp/mcp-plugins.js +107 -0
- package/dist/better-auth/mcp/mcp-token-claims.js +39 -0
- package/dist/better-auth/mcp/request-defaults.js +57 -0
- package/dist/better-auth/mcp/revoke-grants.js +75 -0
- package/dist/better-auth/mcp/setup-gate.js +38 -0
- package/dist/better-auth/mcp/signed-query-guard.js +29 -0
- package/dist/better-auth/new-account-password.d.ts +6 -0
- package/dist/better-auth/new-account-password.js +6 -3
- package/dist/better-auth/origins.d.ts +7 -0
- package/dist/better-auth/origins.js +8 -1
- package/dist/better-auth/password-reset.js +4 -3
- package/dist/better-auth/policy-plugin.js +1 -24
- package/dist/better-auth/sign-in-enforcement.js +6 -1
- package/dist/better-auth/sign-in-setup.js +38 -0
- package/dist/better-auth/sign-up/sign-up-plugin.js +1 -1
- package/dist/better-auth/snake-case-names.js +102 -1
- package/dist/better-auth/staff-checks.js +9 -2
- package/dist/better-auth/staff-plugin.js +9 -2
- package/dist/better-auth/status-route.js +2 -1
- package/dist/better-auth/token-claims.js +19 -0
- package/dist/better-auth/users.d.ts +4 -1
- package/dist/better-auth/users.js +6 -1
- package/dist/check-roles.js +1 -1
- package/dist/create-admin.js +8 -0
- package/dist/deps.d.ts +1 -0
- package/dist/encryption/apply.d.ts +40 -0
- package/dist/encryption/apply.js +99 -0
- package/dist/encryption/binding.d.ts +2 -0
- package/dist/encryption/binding.js +53 -0
- package/dist/encryption/capture-off.js +12 -0
- package/dist/encryption/cascading-keys.d.ts +9 -0
- package/dist/encryption/cascading-keys.js +21 -0
- package/dist/encryption/envelope-open.d.ts +5 -0
- package/dist/encryption/envelope-open.js +57 -0
- package/dist/encryption/envelope-seal.js +38 -0
- package/dist/encryption/keyring.js +60 -0
- package/dist/encryption/mask-stored.js +16 -0
- package/dist/encryption/rebind-check.js +28 -0
- package/dist/encryption/remnants.js +53 -0
- package/dist/encryption/runtime.d.ts +1 -0
- package/dist/encryption/runtime.js +18 -0
- package/dist/encryption/scan.d.ts +4 -0
- package/dist/encryption/scan.js +61 -0
- package/dist/encryption/seal-values.js +30 -0
- package/dist/encryption/shared-columns.js +19 -0
- package/dist/encryption/status.d.ts +39 -0
- package/dist/encryption/status.js +92 -0
- package/dist/encryption/stored-value.js +16 -0
- package/dist/encryption/tools.d.ts +26 -0
- package/dist/encryption/tools.js +34 -0
- package/dist/forms/check-forms.js +1 -1
- package/dist/gotrue/user-routes.js +6 -0
- package/dist/index.d.ts +10 -5
- package/dist/index.js +4 -2
- package/dist/records.js +3 -3
- package/dist/request-access.js +9 -5
- package/dist/rest/encrypted-columns.js +37 -0
- package/dist/rest/table-routes.js +25 -13
- package/dist/reveal-service.js +17 -3
- package/dist/types.d.ts +5 -0
- package/dist/validate-body.js +4 -2
- package/dist/webhooks/subscriber-view.js +14 -1
- package/dist/write-pipeline.js +14 -2
- package/dist/write-shared.js +7 -3
- package/dist/write-update.js +12 -2
- package/package.json +11 -7
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { bindingOf, movedBinding } from "./binding.js";
|
|
2
|
+
import { Sealed, sealText } from "./envelope-seal.js";
|
|
3
|
+
import { columnId, tableId } from "../sql-parts.js";
|
|
4
|
+
import { cascadingKeys } from "./cascading-keys.js";
|
|
5
|
+
import { captureOff } from "./capture-off.js";
|
|
6
|
+
import { scrubHistory, scrubOutbox } from "./remnants.js";
|
|
7
|
+
import { scanColumn } from "./scan.js";
|
|
8
|
+
import { encryptedFields, examine } from "./status.js";
|
|
9
|
+
import { sql } from "kysely";
|
|
10
|
+
//#region src/encryption/apply.ts
|
|
11
|
+
const rebindsField = (rebind, entry, field) => rebind !== void 0 && rebind.resource === entry.name && (rebind.field === void 0 || rebind.field === field.name) ? rebind : void 0;
|
|
12
|
+
const changeOf = (keyring, entry, field, row, rebind) => {
|
|
13
|
+
const binding = bindingOf(entry.model, field, row.record);
|
|
14
|
+
const seal = (plaintext, count) => ({
|
|
15
|
+
address: row.address,
|
|
16
|
+
old: row.stored,
|
|
17
|
+
value: Sealed.textOf(sealText(keyring.current, binding, plaintext)),
|
|
18
|
+
count
|
|
19
|
+
});
|
|
20
|
+
const found = examine(keyring, entry, field, row, binding);
|
|
21
|
+
if (found.kind === "plaintext") return seal(row.stored, "encrypted");
|
|
22
|
+
if (found.kind === "open") return found.kid === keyring.current.id ? void 0 : seal(found.plaintext, "reencrypted");
|
|
23
|
+
if (found.entry.kind !== "failed-authentication" || !rebind) return found.entry;
|
|
24
|
+
const moved = examine(keyring, entry, field, row, movedBinding(binding, rebind.from));
|
|
25
|
+
return moved.kind === "open" ? seal(moved.plaintext, "rebound") : found.entry;
|
|
26
|
+
};
|
|
27
|
+
const write = async (trx, entry, field, change) => {
|
|
28
|
+
const { model } = entry;
|
|
29
|
+
const keys = model.primaryKey.map((name, i) => sql`${columnId(model.fields[name])} = ${change.address.key[i]}`);
|
|
30
|
+
const tenant = model.tenant ? [sql`${columnId(model.fields[model.tenant])} is not distinct from ${change.address.tenant}`] : [];
|
|
31
|
+
const conditions = sql.join([
|
|
32
|
+
...keys,
|
|
33
|
+
...tenant,
|
|
34
|
+
sql`${columnId(field)} = ${change.old}`
|
|
35
|
+
], sql` and `);
|
|
36
|
+
const result = await sql`update ${tableId(model)} set ${columnId(field)} = ${change.value} where ${conditions}`.execute(trx);
|
|
37
|
+
return Number(result.numAffectedRows ?? 0n) > 0;
|
|
38
|
+
};
|
|
39
|
+
const applyField = async (db, entry, field, keyring, changesSchema, rebind) => {
|
|
40
|
+
const applied = {
|
|
41
|
+
resource: entry.name,
|
|
42
|
+
field: field.name,
|
|
43
|
+
encrypted: 0,
|
|
44
|
+
reencrypted: 0,
|
|
45
|
+
rebound: 0,
|
|
46
|
+
skipped: 0,
|
|
47
|
+
unreadable: [],
|
|
48
|
+
historyScrubbed: 0,
|
|
49
|
+
outboxScrubbed: 0
|
|
50
|
+
};
|
|
51
|
+
await scanColumn(db, entry, field, async (rows, connection) => {
|
|
52
|
+
const changes = [];
|
|
53
|
+
for (const row of rows) {
|
|
54
|
+
const change = changeOf(keyring, entry, field, row, rebind);
|
|
55
|
+
if (change && "address" in change) changes.push(change);
|
|
56
|
+
else if (change) applied.unreadable.push(change);
|
|
57
|
+
}
|
|
58
|
+
if (changes.length === 0) return;
|
|
59
|
+
await captureOff(connection, changesSchema, async (trx) => {
|
|
60
|
+
for (const change of changes) if (await write(trx, entry, field, change)) applied[change.count]++;
|
|
61
|
+
else applied.skipped++;
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
applied.historyScrubbed = await scrubHistory(db, changesSchema, entry.model, field);
|
|
65
|
+
applied.outboxScrubbed = await scrubOutbox(db, changesSchema, entry.model, field);
|
|
66
|
+
return applied;
|
|
67
|
+
};
|
|
68
|
+
const keyIdsOf = async (db, entry, field) => {
|
|
69
|
+
const { rows } = await sql`select distinct split_part(${columnId(field)}, ':', 3) as kid from ${tableId(entry.model)} where ${columnId(field)} like 'pbenc:%'`.execute(db);
|
|
70
|
+
return rows.map(({ kid }) => kid);
|
|
71
|
+
};
|
|
72
|
+
const checkKeys = async (db, entries, keyring) => {
|
|
73
|
+
for (const entry of entries) for (const field of encryptedFields(entry)) {
|
|
74
|
+
const kids = await keyIdsOf(db, entry, field);
|
|
75
|
+
if (kids.length > 0 && !kids.some((kid) => keyring.byId.has(kid))) throw new Error(`${entry.name}.${field.name} holds values under keys ${kids.join(", ")} only, none of which this run has (key ${keyring.current.id}): run with the keys the app serves with`);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Brings every encrypted field's column to the current key: plaintext values are encrypted, values under a previous key
|
|
80
|
+
* encrypted again, and with `rebind` values sealed where they were before a rename sealed where they are now. Values that
|
|
81
|
+
* do not open are listed and left alone. Then the fields' old values are removed from the history and the changes not
|
|
82
|
+
* yet dispatched. Its writes capture no change. It refuses to run when a column holds envelopes under none of its keys.
|
|
83
|
+
*/
|
|
84
|
+
const applyEncryption = async (db, entries, keyring, changesSchema, options = {}) => {
|
|
85
|
+
await checkKeys(db, entries, keyring);
|
|
86
|
+
const fields = [];
|
|
87
|
+
const warnings = [];
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
for (const field of encryptedFields(entry)) fields.push(await applyField(db, entry, field, keyring, changesSchema, rebindsField(options.rebind, entry, field)));
|
|
90
|
+
if (encryptedFields(entry).length > 0) warnings.push(...await cascadingKeys(db, entry.model));
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
kid: keyring.current.id,
|
|
94
|
+
fields,
|
|
95
|
+
warnings
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
//#endregion
|
|
99
|
+
export { applyEncryption };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { parseTable } from "@protobase/schema/internal";
|
|
2
|
+
//#region src/encryption/binding.ts
|
|
3
|
+
const textOf = (record, name) => {
|
|
4
|
+
const value = record[name];
|
|
5
|
+
if (value === void 0) throw new Error(`Binding an encrypted value needs the record's "${name}"`);
|
|
6
|
+
return String(value);
|
|
7
|
+
};
|
|
8
|
+
const tableOf = (model, field) => ({
|
|
9
|
+
schema: model.table.schema ?? null,
|
|
10
|
+
table: model.table.name,
|
|
11
|
+
column: field.column
|
|
12
|
+
});
|
|
13
|
+
/**
|
|
14
|
+
* The binding of `field` in `record`. The record must be a row as `fetchRecord`, `insertRecord` and `updateRecord` return
|
|
15
|
+
* it, its columns selected under their field names and passed through `exposeRow`, so its key and tenant are the text the
|
|
16
|
+
* API shows (a lowercase uuid, a bigint as text, a timestamptz as ISO text with milliseconds).
|
|
17
|
+
*/
|
|
18
|
+
const bindingOf = (model, field, record) => ({
|
|
19
|
+
...tableOf(model, field),
|
|
20
|
+
tenant: model.tenant && record[model.tenant] !== null ? textOf(record, model.tenant) : null,
|
|
21
|
+
key: model.primaryKey.map((name) => textOf(record, name))
|
|
22
|
+
});
|
|
23
|
+
/** The binding of a create's first write, whose key the database has not made yet: it is bound again in the same transaction. */
|
|
24
|
+
const pendingBinding = (model, field) => ({
|
|
25
|
+
...tableOf(model, field),
|
|
26
|
+
tenant: null,
|
|
27
|
+
key: null
|
|
28
|
+
});
|
|
29
|
+
/** Where a value was before a rename: `binding` with the table (as `.table()` wrote it) or the column that `from` names. */
|
|
30
|
+
const movedBinding = (binding, from) => {
|
|
31
|
+
const table = from.table === void 0 ? void 0 : parseTable(from.table);
|
|
32
|
+
return {
|
|
33
|
+
...binding,
|
|
34
|
+
...table && {
|
|
35
|
+
schema: table.schema ?? null,
|
|
36
|
+
table: table.name
|
|
37
|
+
},
|
|
38
|
+
...from.column !== void 0 && { column: from.column }
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
/** The additional authenticated data of an envelope: JSON of an array, which no dot in a name or order of a key confuses. */
|
|
42
|
+
const aadOf = (kid, { schema, table, column, tenant, key }) => Buffer.from(JSON.stringify([
|
|
43
|
+
"pbenc",
|
|
44
|
+
1,
|
|
45
|
+
kid,
|
|
46
|
+
schema,
|
|
47
|
+
table,
|
|
48
|
+
column,
|
|
49
|
+
tenant,
|
|
50
|
+
key
|
|
51
|
+
]), "utf8");
|
|
52
|
+
//#endregion
|
|
53
|
+
export { aadOf, bindingOf, movedBinding, pendingBinding };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
//#region src/encryption/capture-off.ts
|
|
3
|
+
/**
|
|
4
|
+
* Runs `work` in a transaction on one connection that change capture skips (`<changes>.capture` set to `off` for this
|
|
5
|
+
* transaction only): rewriting stored values is no change of a record, for history, events or webhooks.
|
|
6
|
+
*/
|
|
7
|
+
const captureOff = (db, changesSchema, work) => db.transaction().execute(async (trx) => {
|
|
8
|
+
await sql`select set_config(${`${changesSchema}.capture`}, 'off', true)`.execute(trx);
|
|
9
|
+
return work(trx);
|
|
10
|
+
});
|
|
11
|
+
//#endregion
|
|
12
|
+
export { captureOff };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ResourceModel } from "@protobase/schema";
|
|
2
|
+
import { Db } from "@protobase/query";
|
|
3
|
+
//#region src/encryption/cascading-keys.d.ts
|
|
4
|
+
/** A foreign key that rewrites part of a resource's key when the record it points at changes its own. */
|
|
5
|
+
export type CascadingKey = {
|
|
6
|
+
resource: string;
|
|
7
|
+
constraint: string;
|
|
8
|
+
};
|
|
9
|
+
//#endregion
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
//#region src/encryption/cascading-keys.ts
|
|
3
|
+
/**
|
|
4
|
+
* The foreign keys of the resource's table, `on update cascade` or `set default`, over a column of its key: a key change
|
|
5
|
+
* of the other record rewrites this record's key, and every encrypted value of the row then no longer opens.
|
|
6
|
+
*/
|
|
7
|
+
const cascadingKeys = async (db, model) => {
|
|
8
|
+
const columns = model.primaryKey.map((name) => model.fields[name].column);
|
|
9
|
+
const { rows } = await sql`select c.conname as "constraint"
|
|
10
|
+
from pg_constraint c
|
|
11
|
+
where c.contype = 'f' and c.confupdtype in ('c', 'd')
|
|
12
|
+
and c.conrelid = to_regclass(${model.table.schema === void 0 ? sql`quote_ident(${model.table.name})` : sql`quote_ident(${model.table.schema}) || '.' || quote_ident(${model.table.name})`})
|
|
13
|
+
and exists (select 1 from pg_attribute a where a.attrelid = c.conrelid and a.attnum = any(c.conkey) and a.attname = any(${columns}::text[]))
|
|
14
|
+
order by c.conname`.execute(db);
|
|
15
|
+
return rows.map(({ constraint }) => ({
|
|
16
|
+
resource: model.name,
|
|
17
|
+
constraint
|
|
18
|
+
}));
|
|
19
|
+
};
|
|
20
|
+
//#endregion
|
|
21
|
+
export { cascadingKeys };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { aadOf } from "./binding.js";
|
|
2
|
+
import "./envelope-seal.js";
|
|
3
|
+
import { createDecipheriv } from "node:crypto";
|
|
4
|
+
//#region src/encryption/envelope-open.ts
|
|
5
|
+
/** A stored value of an encrypted field that does not open. Its message never holds the value. */
|
|
6
|
+
var UnreadableValue = class extends Error {
|
|
7
|
+
kind;
|
|
8
|
+
kid;
|
|
9
|
+
constructor(kind, subject, binding, kid) {
|
|
10
|
+
super(`${subject.resource}.${subject.field} of record ${JSON.stringify(binding.key)} cannot be decrypted: ${kind}${kid ? ` (key ${kid})` : ""}`);
|
|
11
|
+
this.kind = kind;
|
|
12
|
+
this.kid = kid;
|
|
13
|
+
this.name = "UnreadableValue";
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const ivLength = 12;
|
|
17
|
+
const tagLength = 16;
|
|
18
|
+
const parse = (stored) => {
|
|
19
|
+
if (!stored.startsWith("pbenc:")) return "not-encrypted";
|
|
20
|
+
const parts = stored.split(":");
|
|
21
|
+
if (parts.length !== 4 || parts[1] !== "1") return "malformed";
|
|
22
|
+
const [, , kid, payload] = parts;
|
|
23
|
+
if (!/^[A-Za-z0-9_-]{8}$/.test(kid)) return "malformed";
|
|
24
|
+
const bytes = Buffer.from(payload, "base64url");
|
|
25
|
+
if (bytes.toString("base64url") !== payload || bytes.length < 28) return "malformed";
|
|
26
|
+
return {
|
|
27
|
+
kid,
|
|
28
|
+
bytes
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
const decrypt = (aes, aad, bytes) => {
|
|
32
|
+
const decipher = createDecipheriv("aes-256-gcm", aes, bytes.subarray(0, ivLength), { authTagLength: tagLength });
|
|
33
|
+
decipher.setAAD(aad);
|
|
34
|
+
decipher.setAuthTag(bytes.subarray(bytes.length - tagLength));
|
|
35
|
+
const body = decipher.update(bytes.subarray(ivLength, bytes.length - tagLength));
|
|
36
|
+
try {
|
|
37
|
+
return Buffer.concat([body, decipher.final()]).toString("utf8");
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error instanceof Error) return void 0;
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* The plaintext of `stored`, sealed under `binding` with a key of `keyring`, or an `UnreadableValue` thrown with why it
|
|
45
|
+
* does not open. The only place, with the CLI's status and apply, that decrypts.
|
|
46
|
+
*/
|
|
47
|
+
const openText = (keyring, binding, stored, subject) => {
|
|
48
|
+
const parsed = parse(stored);
|
|
49
|
+
if (typeof parsed === "string") throw new UnreadableValue(parsed, subject, binding);
|
|
50
|
+
const key = keyring.byId.get(parsed.kid);
|
|
51
|
+
if (!key) throw new UnreadableValue("unknown-key", subject, binding, parsed.kid);
|
|
52
|
+
const plaintext = decrypt(key.aes, aadOf(parsed.kid, binding), parsed.bytes);
|
|
53
|
+
if (plaintext === void 0) throw new UnreadableValue("failed-authentication", subject, binding, parsed.kid);
|
|
54
|
+
return plaintext;
|
|
55
|
+
};
|
|
56
|
+
//#endregion
|
|
57
|
+
export { UnreadableValue, openText };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { aadOf } from "./binding.js";
|
|
2
|
+
import { createCipheriv, randomBytes } from "node:crypto";
|
|
3
|
+
//#region src/encryption/envelope-seal.ts
|
|
4
|
+
/** The envelope's prefix and version: `pbenc:1:<key id>:<base64url of iv, ciphertext and tag>`. */
|
|
5
|
+
const envelopePrefix = "pbenc:";
|
|
6
|
+
let seal;
|
|
7
|
+
/**
|
|
8
|
+
* A value sealed by `sealText`, the only thing the record writes accept for an encrypted field: nothing else can make
|
|
9
|
+
* one, so a write path that forgets to encrypt throws instead of storing plaintext.
|
|
10
|
+
*/
|
|
11
|
+
var Sealed = class Sealed {
|
|
12
|
+
#text;
|
|
13
|
+
constructor(text) {
|
|
14
|
+
this.#text = text;
|
|
15
|
+
}
|
|
16
|
+
static {
|
|
17
|
+
seal = (text) => new Sealed(text);
|
|
18
|
+
}
|
|
19
|
+
/** The envelope text the column stores. */
|
|
20
|
+
static textOf(value) {
|
|
21
|
+
return value.#text;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
/** Seals `plaintext` with AES-256-GCM under `key`, bound to `binding`, with a fresh random IV. */
|
|
25
|
+
const sealText = (key, binding, plaintext) => {
|
|
26
|
+
const iv = randomBytes(12);
|
|
27
|
+
const cipher = createCipheriv("aes-256-gcm", key.aes, iv, { authTagLength: 16 });
|
|
28
|
+
cipher.setAAD(aadOf(key.id, binding));
|
|
29
|
+
const body = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
30
|
+
const payload = Buffer.concat([
|
|
31
|
+
iv,
|
|
32
|
+
body,
|
|
33
|
+
cipher.getAuthTag()
|
|
34
|
+
]).toString("base64url");
|
|
35
|
+
return seal(`${envelopePrefix}1:${key.id}:${payload}`);
|
|
36
|
+
};
|
|
37
|
+
//#endregion
|
|
38
|
+
export { Sealed, envelopePrefix, sealText };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
//#region src/encryption/keyring.ts
|
|
3
|
+
const currentVariable = "PROTOBASE_ENCRYPTION_KEY";
|
|
4
|
+
const previousVariable = "PROTOBASE_ENCRYPTION_PREVIOUS_KEYS";
|
|
5
|
+
const derive = (root, label) => createHmac("sha256", root).update(label).digest();
|
|
6
|
+
const keyOf = (root) => ({
|
|
7
|
+
id: derive(root, "protobase field encryption key id").toString("base64url").slice(0, 8),
|
|
8
|
+
aes: derive(root, "protobase field encryption key v1")
|
|
9
|
+
});
|
|
10
|
+
const rootOf = ({ text, where }) => {
|
|
11
|
+
if (text === "") throw new Error(`${where} is empty`);
|
|
12
|
+
const bytes = Buffer.from(text, "base64");
|
|
13
|
+
if (bytes.toString("base64") !== text || bytes.length !== 32) throw new Error(`${where} is not 32 bytes in standard base64, such as the output of openssl rand -base64 32`);
|
|
14
|
+
if (bytes.every((byte) => byte === bytes[0])) throw new Error(`${where} is 32 identical bytes, not a random key`);
|
|
15
|
+
return bytes;
|
|
16
|
+
};
|
|
17
|
+
const entriesOf = (env) => {
|
|
18
|
+
const current = {
|
|
19
|
+
text: (env[currentVariable] ?? "").trim(),
|
|
20
|
+
where: currentVariable
|
|
21
|
+
};
|
|
22
|
+
const previous = env[previousVariable];
|
|
23
|
+
if (previous === void 0 || previous.trim() === "") return [current];
|
|
24
|
+
return [current, ...previous.split(",").map((text, i) => ({
|
|
25
|
+
text: text.trim(),
|
|
26
|
+
where: `${previousVariable} entry ${i + 1}`
|
|
27
|
+
}))];
|
|
28
|
+
};
|
|
29
|
+
const checkApart = (entries, env) => {
|
|
30
|
+
for (const name of ["BETTER_AUTH_SECRET", "PROTOBASE_SECRET_KEY"]) {
|
|
31
|
+
const secret = env[name]?.trim();
|
|
32
|
+
const same = secret && entries.find((entry) => entry.text === secret);
|
|
33
|
+
if (same) throw new Error(`${same.where} is the same as ${name}: give encryption a key of its own`);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* The keys from `PROTOBASE_ENCRYPTION_KEY` and the comma separated `PROTOBASE_ENCRYPTION_PREVIOUS_KEYS`. Throws on a key
|
|
38
|
+
* that is not 32 random bytes in base64, a key given twice, or one that is the sign-in or API secret, naming the
|
|
39
|
+
* variable and the entry, never the key.
|
|
40
|
+
*/
|
|
41
|
+
const encryptionKeyring = (env) => {
|
|
42
|
+
const entries = entriesOf(env);
|
|
43
|
+
checkApart(entries, env);
|
|
44
|
+
const byId = /* @__PURE__ */ new Map();
|
|
45
|
+
const seen = /* @__PURE__ */ new Set();
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
const root = rootOf(entry);
|
|
48
|
+
if (seen.has(root.toString("base64"))) throw new Error(`${entry.where} is a key given before`);
|
|
49
|
+
seen.add(root.toString("base64"));
|
|
50
|
+
const key = keyOf(root);
|
|
51
|
+
if (byId.has(key.id)) throw new Error(`${entry.where} has the same key id as a key given before`);
|
|
52
|
+
byId.set(key.id, key);
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
current: byId.values().next().value,
|
|
56
|
+
byId
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
//#endregion
|
|
60
|
+
export { encryptionKeyring };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { encrypted } from "@protobase/schema";
|
|
2
|
+
//#region src/encryption/mask-stored.ts
|
|
3
|
+
/**
|
|
4
|
+
* A stored row as app code gets it: each encrypted field that holds a value as `encrypted`, never the ciphertext, so
|
|
5
|
+
* rules, validators and hooks know whether it is set and nothing else. An empty one stays `null`.
|
|
6
|
+
*/
|
|
7
|
+
const maskEncrypted = (model, row) => {
|
|
8
|
+
const masked = Object.values(model.fields).filter((field) => field.encrypted && row[field.name] != null);
|
|
9
|
+
if (masked.length === 0) return row;
|
|
10
|
+
return {
|
|
11
|
+
...row,
|
|
12
|
+
...Object.fromEntries(masked.map((field) => [field.name, encrypted]))
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
16
|
+
export { maskEncrypted };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { parseTable } from "@protobase/schema/internal";
|
|
2
|
+
//#region src/encryption/rebind-check.ts
|
|
3
|
+
const sources = (entry, rebind) => {
|
|
4
|
+
const table = rebind.from.table === void 0 ? entry.model.table : parseTable(rebind.from.table);
|
|
5
|
+
return Object.values(entry.model.fields).filter((field) => field.encrypted && (rebind.field === void 0 || field.name === rebind.field)).map((field) => ({
|
|
6
|
+
table,
|
|
7
|
+
column: rebind.from.column ?? field.column
|
|
8
|
+
}));
|
|
9
|
+
};
|
|
10
|
+
const sameTable = (a, b) => a.name === b.name && (a.schema === void 0 || b.schema === void 0 || a.schema === b.schema);
|
|
11
|
+
/**
|
|
12
|
+
* Why `rebind` cannot run, or `undefined`: it must name a resource with encrypted fields, an encrypted field of it, and
|
|
13
|
+
* where they were before; and no resource may still encrypt a field over that place, whose values it would take over.
|
|
14
|
+
*/
|
|
15
|
+
const rebindProblem = (entries, rebind) => {
|
|
16
|
+
const entry = entries.find((candidate) => candidate.name === rebind.resource);
|
|
17
|
+
if (!entry) return `There is no resource "${rebind.resource}"`;
|
|
18
|
+
if (rebind.field !== void 0 && !entry.model.fields[rebind.field]?.encrypted) return `${rebind.resource}.${rebind.field} is not an encrypted field`;
|
|
19
|
+
if (!Object.values(entry.model.fields).some((field) => field.encrypted)) return `${rebind.resource} has no encrypted fields`;
|
|
20
|
+
if (rebind.from.table === void 0 && rebind.from.column === void 0) return "Name the table or the column the values were in before";
|
|
21
|
+
if (rebind.from.column !== void 0 && rebind.field === void 0) return "Name the field whose column was renamed";
|
|
22
|
+
for (const { table, column } of sources(entry, rebind)) {
|
|
23
|
+
const owner = entries.find((other) => sameTable(other.model.table, table) && Object.values(other.model.fields).some((field) => field.encrypted && field.column === column));
|
|
24
|
+
if (owner) return `${owner.name} still encrypts a field over ${table.schema ? `${table.schema}.` : ""}${table.name}.${column}, so its values are not ${rebind.resource}'s to take over`;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
//#endregion
|
|
28
|
+
export { rebindProblem };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { quoteIdent } from "../changes/capture-sql.js";
|
|
2
|
+
import { captureOff } from "./capture-off.js";
|
|
3
|
+
import { sql } from "kysely";
|
|
4
|
+
//#region src/encryption/remnants.ts
|
|
5
|
+
const history = {
|
|
6
|
+
table: "history",
|
|
7
|
+
old: "before",
|
|
8
|
+
new: "after",
|
|
9
|
+
key: (field) => field.name
|
|
10
|
+
};
|
|
11
|
+
const outbox = {
|
|
12
|
+
table: "outbox",
|
|
13
|
+
old: "old",
|
|
14
|
+
new: "new",
|
|
15
|
+
key: (field) => field.column
|
|
16
|
+
};
|
|
17
|
+
const batchSize = 500;
|
|
18
|
+
const tableOf = (schema, store) => `${quoteIdent(schema)}.${quoteIdent(store.table)}`;
|
|
19
|
+
const exists = async (db, schema, store) => {
|
|
20
|
+
const { rows } = await sql`select to_regclass(${tableOf(schema, store)}) is not null as found`.execute(db);
|
|
21
|
+
return rows[0].found;
|
|
22
|
+
};
|
|
23
|
+
const holding = (store, model, field) => sql`"resource" = ${model.name} and (${sql.id(store.old)} ? ${store.key(field)}::text or ${sql.id(store.new)} ? ${store.key(field)}::text)`;
|
|
24
|
+
const count = async (db, schema, store, model, field) => {
|
|
25
|
+
if (!await exists(db, schema, store)) return 0;
|
|
26
|
+
const { rows } = await sql`select count(*)::text as count from ${sql.raw(tableOf(schema, store))} where ${holding(store, model, field)}`.execute(db);
|
|
27
|
+
return Number(rows[0].count);
|
|
28
|
+
};
|
|
29
|
+
const scrub = async (db, schema, store, model, field) => {
|
|
30
|
+
if (!await exists(db, schema, store)) return 0;
|
|
31
|
+
const table = sql.raw(tableOf(schema, store));
|
|
32
|
+
const key = sql`${store.key(field)}::text`;
|
|
33
|
+
let total = 0;
|
|
34
|
+
for (;;) {
|
|
35
|
+
const scrubbed = await captureOff(db, schema, async (trx) => {
|
|
36
|
+
const result = await sql`with batch as (select "id" from ${table} where ${holding(store, model, field)} limit ${sql.raw(String(batchSize))})
|
|
37
|
+
update ${table} as t set ${sql.id(store.old)} = t.${sql.id(store.old)} - ${key}, ${sql.id(store.new)} = t.${sql.id(store.new)} - ${key} from batch where t."id" = batch."id"`.execute(trx);
|
|
38
|
+
return Number(result.numAffectedRows ?? 0n);
|
|
39
|
+
});
|
|
40
|
+
total += scrubbed;
|
|
41
|
+
if (scrubbed < batchSize) return total;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
/** The history rows of `model` that still hold `field`, kept from before it was encrypted. */
|
|
45
|
+
const historyRemnants = (db, schema, model, field) => count(db, schema, history, model, field);
|
|
46
|
+
/** Removes `field` from the history of `model`, returning how many rows held it. */
|
|
47
|
+
const scrubHistory = (db, schema, model, field) => scrub(db, schema, history, model, field);
|
|
48
|
+
/** The changes of `model` not yet dispatched that still hold `field`'s column. */
|
|
49
|
+
const outboxRemnants = (db, schema, model, field) => count(db, schema, outbox, model, field);
|
|
50
|
+
/** Removes `field`'s column from the changes of `model` not yet dispatched, returning how many rows held it. */
|
|
51
|
+
const scrubOutbox = (db, schema, model, field) => scrub(db, schema, outbox, model, field);
|
|
52
|
+
//#endregion
|
|
53
|
+
export { historyRemnants, outboxRemnants, scrubHistory, scrubOutbox };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import { ResourceModel } from "@protobase/schema";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { sharedColumnProblems } from "./shared-columns.js";
|
|
2
|
+
import { encryptionKeyring } from "./keyring.js";
|
|
3
|
+
//#region src/encryption/runtime.ts
|
|
4
|
+
const encryptedFieldNames = (models) => models.flatMap((model) => Object.values(model.fields).flatMap((field) => field.encrypted ? [`${model.name}.${field.name}`] : []));
|
|
5
|
+
/**
|
|
6
|
+
* The runtime, when a resource has an encrypted field. It fails at startup when another resource over the same column
|
|
7
|
+
* would write plaintext into it or bind it differently, or when the key is missing or unfit. It needs no database.
|
|
8
|
+
*/
|
|
9
|
+
const createEncryptionRuntime = ({ models, env }) => {
|
|
10
|
+
const fields = encryptedFieldNames(models);
|
|
11
|
+
if (fields.length === 0) return void 0;
|
|
12
|
+
const problems = sharedColumnProblems(models);
|
|
13
|
+
if (problems.length > 0) throw new Error(problems.join("; "));
|
|
14
|
+
if (!env.PROTOBASE_ENCRYPTION_KEY?.trim()) throw new Error(`Encrypted fields (${fields.join(", ")}) need a key: set PROTOBASE_ENCRYPTION_KEY to 32 random bytes in base64, such as the output of openssl rand -base64 32`);
|
|
15
|
+
return { keyring: encryptionKeyring(env) };
|
|
16
|
+
};
|
|
17
|
+
//#endregion
|
|
18
|
+
export { createEncryptionRuntime };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { aliasedColumn, columnId, tableId } from "../sql-parts.js";
|
|
2
|
+
import { exposeRow } from "../rows.js";
|
|
3
|
+
import { sql } from "kysely";
|
|
4
|
+
//#region src/encryption/scan.ts
|
|
5
|
+
const batchSize = 500;
|
|
6
|
+
const cursor = sql.id("pb_encryption_scan");
|
|
7
|
+
const keyAlias = (i) => `__pb_key_${i}`;
|
|
8
|
+
const tenantAlias = "__pb_tenant";
|
|
9
|
+
/** The fields a binding needs, and the field itself. */
|
|
10
|
+
const scannedFields = (entry, field) => {
|
|
11
|
+
const { model } = entry;
|
|
12
|
+
return [.../* @__PURE__ */ new Set([
|
|
13
|
+
...model.primaryKey,
|
|
14
|
+
...model.tenant ? [model.tenant] : [],
|
|
15
|
+
field.name
|
|
16
|
+
])].map((name) => model.fields[name]);
|
|
17
|
+
};
|
|
18
|
+
const declareScan = (entry, field) => {
|
|
19
|
+
const { model } = entry;
|
|
20
|
+
const keys = model.primaryKey.map((name, i) => sql`${columnId(model.fields[name])}::text as ${sql.id(keyAlias(i))}`);
|
|
21
|
+
const tenant = model.tenant ? [sql`${columnId(model.fields[model.tenant])}::text as ${sql.id(tenantAlias)}`] : [];
|
|
22
|
+
const columns = sql.join([
|
|
23
|
+
...scannedFields(entry, field).map(aliasedColumn),
|
|
24
|
+
...keys,
|
|
25
|
+
...tenant
|
|
26
|
+
]);
|
|
27
|
+
return sql`declare ${cursor} no scroll cursor with hold for select ${columns} from ${tableId(model)} where ${columnId(field)} is not null`;
|
|
28
|
+
};
|
|
29
|
+
const scanned = (entry, field, row) => ({
|
|
30
|
+
record: exposeRow(entry.model, row),
|
|
31
|
+
address: {
|
|
32
|
+
key: entry.model.primaryKey.map((_, i) => String(row[keyAlias(i)])),
|
|
33
|
+
tenant: row[tenantAlias] ?? null
|
|
34
|
+
},
|
|
35
|
+
stored: String(row[field.name])
|
|
36
|
+
});
|
|
37
|
+
/**
|
|
38
|
+
* Reads every row of the table whose column `field` is not null, every tenant and soft-deleted rows included, in batches
|
|
39
|
+
* handed to `onBatch` with the connection the scan runs on. A holdable cursor keeps the rows as they were when the scan
|
|
40
|
+
* began, so each comes once without an order, and the batches' writes run on the same connection in transactions of
|
|
41
|
+
* their own: a pool of one connection is enough. Rows written after the scan began are not in it.
|
|
42
|
+
*/
|
|
43
|
+
const scanColumn = (db, entry, field, onBatch) => db.connection().execute(async (connection) => {
|
|
44
|
+
let declared = false;
|
|
45
|
+
try {
|
|
46
|
+
await sql`begin`.execute(connection);
|
|
47
|
+
await declareScan(entry, field).execute(connection);
|
|
48
|
+
declared = true;
|
|
49
|
+
await sql`commit`.execute(connection);
|
|
50
|
+
for (;;) {
|
|
51
|
+
const { rows } = await sql`fetch ${sql.raw(String(batchSize))} from ${cursor}`.execute(connection);
|
|
52
|
+
if (rows.length === 0) return;
|
|
53
|
+
await onBatch(rows.map((row) => scanned(entry, field, row)), connection);
|
|
54
|
+
}
|
|
55
|
+
} finally {
|
|
56
|
+
await sql`rollback`.execute(connection);
|
|
57
|
+
if (declared) await sql`close ${cursor}`.execute(connection);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
//#endregion
|
|
61
|
+
export { scanColumn, scannedFields };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { sealText } from "./envelope-seal.js";
|
|
2
|
+
//#region src/encryption/seal-values.ts
|
|
3
|
+
const encryptedIn = (model, values) => Object.keys(values).flatMap((name) => {
|
|
4
|
+
const field = model.fields[name];
|
|
5
|
+
return field?.encrypted && values[name] !== null ? [field] : [];
|
|
6
|
+
});
|
|
7
|
+
/** Whether `values` set an encrypted field to a value. */
|
|
8
|
+
const hasEncryptedValues = (model, values) => encryptedIn(model, values).length > 0;
|
|
9
|
+
/** The encrypted fields of `values` that hold a value. */
|
|
10
|
+
const encryptedValuesOf = (model, values) => Object.fromEntries(encryptedIn(model, values).map((field) => [field.name, values[field.name]]));
|
|
11
|
+
/**
|
|
12
|
+
* `values` with each encrypted field's value sealed under `binding(field)`. What was given is always sealed, even text
|
|
13
|
+
* that looks like an envelope, so a client cannot store another record's ciphertext as its own.
|
|
14
|
+
*/
|
|
15
|
+
const sealValues = (runtime, model, values, binding) => {
|
|
16
|
+
const fields = encryptedIn(model, values);
|
|
17
|
+
if (fields.length === 0) return values;
|
|
18
|
+
if (!runtime) throw new Error(`"${model.name}" has encrypted fields but no encryption runtime`);
|
|
19
|
+
const sealed = fields.map((field) => {
|
|
20
|
+
const value = values[field.name];
|
|
21
|
+
if (typeof value !== "string") throw new Error(`"${field.name}" is encrypted and takes text only`);
|
|
22
|
+
return [field.name, sealText(runtime.keyring.current, binding(field), value)];
|
|
23
|
+
});
|
|
24
|
+
return {
|
|
25
|
+
...values,
|
|
26
|
+
...Object.fromEntries(sealed)
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
//#endregion
|
|
30
|
+
export { encryptedValuesOf, hasEncryptedValues, sealValues };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
//#region src/encryption/shared-columns.ts
|
|
2
|
+
/** Whether two resources are over one table: the same name, and the same schema or either one naming none. */
|
|
3
|
+
const sameTable = (a, b) => a.table.name === b.table.name && (a.table.schema === void 0 || b.table.schema === void 0 || a.table.schema === b.table.schema);
|
|
4
|
+
const columnOf = (model, name) => name === void 0 ? null : model.fields[name]?.column ?? name;
|
|
5
|
+
const bindsAlike = (a, b) => (a.table.schema ?? null) === (b.table.schema ?? null) && columnOf(a, a.tenant) === columnOf(b, b.tenant) && JSON.stringify(a.primaryKey.map((name) => columnOf(a, name))) === JSON.stringify(b.primaryKey.map((name) => columnOf(b, name)));
|
|
6
|
+
const isOver = (field, column) => field.column === column || (field.relation?.columns.includes(column) ?? false);
|
|
7
|
+
const ownProblems = (model, field) => Object.values(model.fields).filter((other) => other !== field && isOver(other, field.column)).map((other) => `${model.name}.${other.name} is over ${model.table.name}.${field.column}, which ${model.name}.${field.name} encrypts: leave the column out of it`);
|
|
8
|
+
/**
|
|
9
|
+
* Every other field over the column of an encrypted field that would write plaintext into it or bind its values
|
|
10
|
+
* differently: in its own resource, any; in another resource, one that is not `.encrypted()` too over the same
|
|
11
|
+
* `.table()`, tenant and key.
|
|
12
|
+
*/
|
|
13
|
+
const sharedColumnProblems = (models) => models.flatMap((model) => Object.values(model.fields).filter((field) => field.encrypted).flatMap((field) => [...ownProblems(model, field), ...models.filter((other) => other !== model && sameTable(model, other)).flatMap((other) => {
|
|
14
|
+
const shared = Object.values(other.fields).find((candidate) => isOver(candidate, field.column));
|
|
15
|
+
if (!shared || shared.encrypted && shared.column === field.column && bindsAlike(model, other)) return [];
|
|
16
|
+
return [`${other.name}.${shared.name} is over ${model.table.name}.${field.column}, which ${model.name} encrypts: declare it .encrypted() too, with the same table, tenant and key, or leave the column out`];
|
|
17
|
+
})]));
|
|
18
|
+
//#endregion
|
|
19
|
+
export { sameTable, sharedColumnProblems };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import "../registry.js";
|
|
2
|
+
import "./binding.js";
|
|
3
|
+
import { CascadingKey } from "./cascading-keys.js";
|
|
4
|
+
import { UnreadableKind } from "./envelope-open.js";
|
|
5
|
+
import "./scan.js";
|
|
6
|
+
import "@protobase/schema";
|
|
7
|
+
import { Db } from "@protobase/query";
|
|
8
|
+
//#region src/encryption/status.d.ts
|
|
9
|
+
/** A stored value that does not open, by its record's key: never the value. */
|
|
10
|
+
export type UnreadableEntry = {
|
|
11
|
+
key: string[];
|
|
12
|
+
kid?: string;
|
|
13
|
+
kind: UnreadableKind;
|
|
14
|
+
};
|
|
15
|
+
/** What an encrypted field's column holds, and where its old plaintext remains. */
|
|
16
|
+
export type FieldStatus = {
|
|
17
|
+
resource: string;
|
|
18
|
+
field: string;
|
|
19
|
+
/** The values that open, by the id of the key they are under. */
|
|
20
|
+
byKey: Record<string, number>;
|
|
21
|
+
plaintext: number;
|
|
22
|
+
unreadable: UnreadableEntry[];
|
|
23
|
+
/** History rows and undispatched changes that still hold the field from before it was encrypted. */
|
|
24
|
+
history: number;
|
|
25
|
+
outbox: number;
|
|
26
|
+
};
|
|
27
|
+
/** Envelopes in a text field that is not encrypted, such as one whose `.encrypted()` was removed. */
|
|
28
|
+
export type StrayValues = {
|
|
29
|
+
resource: string;
|
|
30
|
+
field: string;
|
|
31
|
+
count: number;
|
|
32
|
+
};
|
|
33
|
+
export type EncryptionStatus = {
|
|
34
|
+
kid: string;
|
|
35
|
+
fields: FieldStatus[];
|
|
36
|
+
stray: StrayValues[];
|
|
37
|
+
cascading: CascadingKey[];
|
|
38
|
+
};
|
|
39
|
+
//#endregion
|