@pikku/kysely 0.13.21 → 0.13.23
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 +38 -0
- package/dist/src/classification-crypto.d.ts +72 -0
- package/dist/src/classification-crypto.js +104 -0
- package/dist/src/classification-plugin.d.ts +28 -0
- package/dist/src/classification-plugin.js +199 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +2 -0
- package/dist/src/kysely-lock-vault.d.ts +34 -0
- package/dist/src/kysely-lock-vault.js +77 -0
- package/dist/src/kysely-secret-service.js +1 -1
- package/dist/src/schema/data-lock.schema.d.ts +15 -0
- package/dist/src/schema/data-lock.schema.js +27 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/knowledge/decisions/classification-driven-encryption-happens-in-a-plugin.md +71 -0
- package/knowledge/decisions/index.md +15 -0
- package/knowledge/decisions/keks-are-scoped-by-purpose-as-well-as-version.md +45 -0
- package/knowledge/decisions/the-plugin-is-given-a-resolver-never-a-key.md +45 -0
- package/knowledge/index.md +18 -0
- package/package.json +2 -2
- package/src/classification-crypto.test.ts +143 -0
- package/src/classification-crypto.ts +163 -0
- package/src/classification-plugin.pglite.test.ts +226 -0
- package/src/classification-plugin.test.ts +265 -0
- package/src/classification-plugin.ts +249 -0
- package/src/index.ts +14 -0
- package/src/kysely-secret-service.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
+
## 0.13.23
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 80eb5c0: Encrypt classified columns from the generated manifest.
|
|
6
|
+
|
|
7
|
+
`ClassificationPlugin` reads the per-column classification manifest and decrypts `wrapped` and `sealed` columns transparently on the way out. Writes are not transparent — Kysely's `transformQuery` is synchronous and WebCrypto is not — so plaintext heading for a classified column **throws** instead, and values are produced by `ClassificationCrypto.encryptColumn()`. A forgotten call site is a loud error rather than a silent plaintext row. The stored envelope is self-describing (`pikku1.<keyId>.<version>.<wrappedDek>.<ciphertext>`), so a row records which key opens it without a schema change to every table.
|
|
8
|
+
|
|
9
|
+
`keyId` now flows from the hand-authored `db/annotations.ts` through `pikku db migrate` into `classification.gen.ts`. It is emitted only for `wrapped` and `sealed` columns: naming a key on a plain column would claim a protection it does not have, and a hashed column has no key at all — the hash is the lookup key.
|
|
10
|
+
|
|
11
|
+
- Updated dependencies [80eb5c0]
|
|
12
|
+
- Updated dependencies [2252016]
|
|
13
|
+
- @pikku/core@0.12.98
|
|
14
|
+
|
|
15
|
+
## 0.13.22
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- 4450b2a: Name the missing key when a secret is not found.
|
|
20
|
+
|
|
21
|
+
Every `SecretService` threw a bare `Requested secret not found`. In a deployed
|
|
22
|
+
runtime the stack is minified, so the message was the only evidence there was —
|
|
23
|
+
and it identified neither the key nor the service. Each implementation now names
|
|
24
|
+
the key it looked for; the better-auth middlewares that skip on an absent secret
|
|
25
|
+
match the prefix through one shared predicate instead of the whole string.
|
|
26
|
+
|
|
27
|
+
- Updated dependencies [4058c3a]
|
|
28
|
+
- Updated dependencies [4058c3a]
|
|
29
|
+
- Updated dependencies [4058c3a]
|
|
30
|
+
- Updated dependencies [4058c3a]
|
|
31
|
+
- Updated dependencies [4058c3a]
|
|
32
|
+
- Updated dependencies [4058c3a]
|
|
33
|
+
- Updated dependencies [114c079]
|
|
34
|
+
- Updated dependencies [4450b2a]
|
|
35
|
+
- Updated dependencies [4058c3a]
|
|
36
|
+
- Updated dependencies [4058c3a]
|
|
37
|
+
- @pikku/core@0.12.93
|
|
38
|
+
|
|
1
39
|
## 0.13.21
|
|
2
40
|
|
|
3
41
|
### Patch Changes
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { DEFAULT_KEY_ID } from '@pikku/core/classification';
|
|
2
|
+
import type { WrappedValue } from '@pikku/core/classification';
|
|
3
|
+
/**
|
|
4
|
+
* The key a column is protected by when its classification names none. A
|
|
5
|
+
* deployment that never scopes its keys stores this id in every row and still
|
|
6
|
+
* gets the scoping seam for free.
|
|
7
|
+
*
|
|
8
|
+
* Re-exported from core rather than declared again: the same constant decides
|
|
9
|
+
* which record `initialize` mints and which one a stored envelope names, and
|
|
10
|
+
* two copies of it would only ever be found to disagree by a row that could
|
|
11
|
+
* not be opened.
|
|
12
|
+
*/
|
|
13
|
+
export { DEFAULT_KEY_ID };
|
|
14
|
+
/** The parts of a stored envelope, as they came off the column. */
|
|
15
|
+
export interface ColumnEnvelope {
|
|
16
|
+
keyId: string;
|
|
17
|
+
keyVersion: number;
|
|
18
|
+
wrappedDek: string;
|
|
19
|
+
ciphertext: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A KEK for one `keyId`, plus the version it was derived at.
|
|
23
|
+
*
|
|
24
|
+
* The version is the resolver's to report rather than the caller's to ask for:
|
|
25
|
+
* on a write it stamps the row, and on a read the row already carries the
|
|
26
|
+
* version the value was written under.
|
|
27
|
+
*/
|
|
28
|
+
export interface ResolvedKEK {
|
|
29
|
+
kek: CryptoKey;
|
|
30
|
+
keyVersion: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Maps a `keyId` — and, when reading, the version the row was written under —
|
|
34
|
+
* to the KEK that opens it.
|
|
35
|
+
*
|
|
36
|
+
* This is the seam the whole design turns on. An unlock gate, a per-tenant
|
|
37
|
+
* lookup or a KMS call all replace this one function without reshaping anything
|
|
38
|
+
* that calls it.
|
|
39
|
+
*/
|
|
40
|
+
export type KEKResolver = (keyId: string, keyVersion?: number) => Promise<ResolvedKEK>;
|
|
41
|
+
/**
|
|
42
|
+
* Read a stored envelope, or null when the value is not one.
|
|
43
|
+
*
|
|
44
|
+
* Every part is base64url except the version, so `.` cannot occur inside a part
|
|
45
|
+
* and a fixed split is unambiguous — a `keyId` may contain dots.
|
|
46
|
+
*/
|
|
47
|
+
export declare const parseColumnEnvelope: (value: unknown) => ColumnEnvelope | null;
|
|
48
|
+
export declare const isColumnEnvelope: (value: unknown) => boolean;
|
|
49
|
+
export interface ClassificationCryptoOptions {
|
|
50
|
+
resolveKEK: KEKResolver;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Envelope encryption for a single classified column.
|
|
54
|
+
*
|
|
55
|
+
* Unlike `KyselySecretService`, which owns its table and can afford a column
|
|
56
|
+
* per envelope part, a classified column is one column in someone else's table.
|
|
57
|
+
* So the envelope is self-describing: the `keyId` and `keyVersion` travel in
|
|
58
|
+
* the value, which is what lets a row record the key that protects it without
|
|
59
|
+
* a schema change to every table that has a secret in it.
|
|
60
|
+
*/
|
|
61
|
+
export declare class ClassificationCrypto {
|
|
62
|
+
private readonly resolveKEK;
|
|
63
|
+
constructor(options: ClassificationCryptoOptions);
|
|
64
|
+
encryptColumn(keyId: string, value: unknown): Promise<WrappedValue>;
|
|
65
|
+
decryptColumn<T = unknown>(value: unknown): Promise<T>;
|
|
66
|
+
/**
|
|
67
|
+
* Move a value to another key by re-wrapping its DEK. The ciphertext is
|
|
68
|
+
* untouched, so the cost is a constant per row rather than proportional to
|
|
69
|
+
* the value — the property that makes rescoping and rotation affordable.
|
|
70
|
+
*/
|
|
71
|
+
rewrapColumn(value: unknown, newKeyId: string): Promise<WrappedValue>;
|
|
72
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { envelopeDecrypt, envelopeEncrypt, envelopeRewrap, } from '@pikku/core/crypto-utils';
|
|
2
|
+
import { DEFAULT_KEY_ID } from '@pikku/core/classification';
|
|
3
|
+
/**
|
|
4
|
+
* The key a column is protected by when its classification names none. A
|
|
5
|
+
* deployment that never scopes its keys stores this id in every row and still
|
|
6
|
+
* gets the scoping seam for free.
|
|
7
|
+
*
|
|
8
|
+
* Re-exported from core rather than declared again: the same constant decides
|
|
9
|
+
* which record `initialize` mints and which one a stored envelope names, and
|
|
10
|
+
* two copies of it would only ever be found to disagree by a row that could
|
|
11
|
+
* not be opened.
|
|
12
|
+
*/
|
|
13
|
+
export { DEFAULT_KEY_ID };
|
|
14
|
+
const ENVELOPE_PREFIX = 'pikku1';
|
|
15
|
+
const encodePart = (value) => Buffer.from(value, 'utf8').toString('base64url');
|
|
16
|
+
const decodePart = (value) => Buffer.from(value, 'base64url').toString('utf8');
|
|
17
|
+
const serializeEnvelope = (envelope) => [
|
|
18
|
+
ENVELOPE_PREFIX,
|
|
19
|
+
encodePart(envelope.keyId),
|
|
20
|
+
String(envelope.keyVersion),
|
|
21
|
+
envelope.wrappedDek,
|
|
22
|
+
envelope.ciphertext,
|
|
23
|
+
].join('.');
|
|
24
|
+
/**
|
|
25
|
+
* Read a stored envelope, or null when the value is not one.
|
|
26
|
+
*
|
|
27
|
+
* Every part is base64url except the version, so `.` cannot occur inside a part
|
|
28
|
+
* and a fixed split is unambiguous — a `keyId` may contain dots.
|
|
29
|
+
*/
|
|
30
|
+
export const parseColumnEnvelope = (value) => {
|
|
31
|
+
if (typeof value !== 'string')
|
|
32
|
+
return null;
|
|
33
|
+
const parts = value.split('.');
|
|
34
|
+
if (parts.length !== 5 || parts[0] !== ENVELOPE_PREFIX)
|
|
35
|
+
return null;
|
|
36
|
+
const [, encodedKeyId, rawVersion, wrappedDek, ciphertext] = parts;
|
|
37
|
+
const keyVersion = Number(rawVersion);
|
|
38
|
+
if (!Number.isInteger(keyVersion) || !wrappedDek || !ciphertext)
|
|
39
|
+
return null;
|
|
40
|
+
try {
|
|
41
|
+
return {
|
|
42
|
+
keyId: decodePart(encodedKeyId),
|
|
43
|
+
keyVersion,
|
|
44
|
+
wrappedDek,
|
|
45
|
+
ciphertext,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
export const isColumnEnvelope = (value) => parseColumnEnvelope(value) !== null;
|
|
53
|
+
/**
|
|
54
|
+
* Envelope encryption for a single classified column.
|
|
55
|
+
*
|
|
56
|
+
* Unlike `KyselySecretService`, which owns its table and can afford a column
|
|
57
|
+
* per envelope part, a classified column is one column in someone else's table.
|
|
58
|
+
* So the envelope is self-describing: the `keyId` and `keyVersion` travel in
|
|
59
|
+
* the value, which is what lets a row record the key that protects it without
|
|
60
|
+
* a schema change to every table that has a secret in it.
|
|
61
|
+
*/
|
|
62
|
+
export class ClassificationCrypto {
|
|
63
|
+
resolveKEK;
|
|
64
|
+
constructor(options) {
|
|
65
|
+
this.resolveKEK = options.resolveKEK;
|
|
66
|
+
}
|
|
67
|
+
async encryptColumn(keyId, value) {
|
|
68
|
+
const { kek, keyVersion } = await this.resolveKEK(keyId);
|
|
69
|
+
const { ciphertext, wrappedDEK } = await envelopeEncrypt(kek, value);
|
|
70
|
+
return serializeEnvelope({
|
|
71
|
+
keyId,
|
|
72
|
+
keyVersion,
|
|
73
|
+
wrappedDek: wrappedDEK,
|
|
74
|
+
ciphertext,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
async decryptColumn(value) {
|
|
78
|
+
const envelope = parseColumnEnvelope(value);
|
|
79
|
+
if (!envelope) {
|
|
80
|
+
throw new Error('Value is not a pikku column envelope');
|
|
81
|
+
}
|
|
82
|
+
const { kek } = await this.resolveKEK(envelope.keyId, envelope.keyVersion);
|
|
83
|
+
return envelopeDecrypt(kek, envelope.ciphertext, envelope.wrappedDek);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Move a value to another key by re-wrapping its DEK. The ciphertext is
|
|
87
|
+
* untouched, so the cost is a constant per row rather than proportional to
|
|
88
|
+
* the value — the property that makes rescoping and rotation affordable.
|
|
89
|
+
*/
|
|
90
|
+
async rewrapColumn(value, newKeyId) {
|
|
91
|
+
const envelope = parseColumnEnvelope(value);
|
|
92
|
+
if (!envelope) {
|
|
93
|
+
throw new Error('Value is not a pikku column envelope');
|
|
94
|
+
}
|
|
95
|
+
const { kek: oldKEK } = await this.resolveKEK(envelope.keyId, envelope.keyVersion);
|
|
96
|
+
const { kek: newKEK, keyVersion } = await this.resolveKEK(newKeyId);
|
|
97
|
+
return serializeEnvelope({
|
|
98
|
+
keyId: newKeyId,
|
|
99
|
+
keyVersion,
|
|
100
|
+
wrappedDek: await envelopeRewrap(oldKEK, newKEK, envelope.wrappedDek),
|
|
101
|
+
ciphertext: envelope.ciphertext,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { KyselyPlugin } from 'kysely';
|
|
2
|
+
import type { ClassificationManifest } from '@pikku/core/classification';
|
|
3
|
+
import { type ClassificationCrypto } from './classification-crypto.js';
|
|
4
|
+
export interface CreateClassificationPluginOptions {
|
|
5
|
+
manifest: ClassificationManifest;
|
|
6
|
+
crypto: ClassificationCrypto;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Make a column's at-rest form a property of the column rather than of the
|
|
10
|
+
* developer's memory.
|
|
11
|
+
*
|
|
12
|
+
* Reads are transparent: a `wrapped` column comes back decrypted, so
|
|
13
|
+
* application code never sees an envelope. `hashed` and `plain` columns are
|
|
14
|
+
* untouched, which is what keeps the rest of the schema queryable.
|
|
15
|
+
*
|
|
16
|
+
* Writes are *guarded* rather than transformed, and the reason is Kysely's
|
|
17
|
+
* shape, not a preference: `transformQuery` is synchronous and WebCrypto is
|
|
18
|
+
* not, so a plugin cannot encrypt on the way in. Encrypting at the call site
|
|
19
|
+
* and letting a forgotten call slip through silently is the failure this
|
|
20
|
+
* design exists to prevent, so the guard refuses any plaintext heading for a
|
|
21
|
+
* `wrapped` or `sealed` column. Values are produced with
|
|
22
|
+
* `ClassificationCrypto.encryptColumn`.
|
|
23
|
+
*
|
|
24
|
+
* A `sealed` column is never decrypted on the way out — the application holds
|
|
25
|
+
* only the public half by definition, so returning it as stored is correct
|
|
26
|
+
* rather than a gap.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createClassificationPlugin(options: CreateClassificationPluginOptions): KyselyPlugin;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { DEFAULT_KEY_ID, isColumnEnvelope, } from './classification-crypto.js';
|
|
2
|
+
function snakeToCamel(name) {
|
|
3
|
+
return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Flatten the manifest into a table-qualified lookup plus a bare column-name
|
|
7
|
+
* fallback, both indexed under snake_case and camelCase so the plugin works on
|
|
8
|
+
* either side of `CamelCasePlugin` in the array.
|
|
9
|
+
*
|
|
10
|
+
* A bare name two tables disagree about is dropped rather than letting the last
|
|
11
|
+
* one win: on a joined query it could carry either, and guessing wrong means
|
|
12
|
+
* either handing back ciphertext or refusing a legitimate write. The qualified
|
|
13
|
+
* map still resolves it whenever the query names its tables.
|
|
14
|
+
*/
|
|
15
|
+
function buildManifest(manifest) {
|
|
16
|
+
const qualified = new Map();
|
|
17
|
+
const flat = new Map();
|
|
18
|
+
const ambiguous = new Set();
|
|
19
|
+
const assignFlat = (key, column) => {
|
|
20
|
+
const existing = flat.get(key);
|
|
21
|
+
if (existing && existing.keyId !== column.keyId) {
|
|
22
|
+
ambiguous.add(key);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
flat.set(key, column);
|
|
26
|
+
};
|
|
27
|
+
for (const [table, columns] of Object.entries(manifest.tables)) {
|
|
28
|
+
for (const [name, classification] of Object.entries(columns)) {
|
|
29
|
+
const form = classification.form;
|
|
30
|
+
if (form !== 'wrapped' && form !== 'sealed')
|
|
31
|
+
continue;
|
|
32
|
+
const column = {
|
|
33
|
+
form,
|
|
34
|
+
keyId: classification.keyId ?? DEFAULT_KEY_ID,
|
|
35
|
+
};
|
|
36
|
+
const camel = snakeToCamel(name);
|
|
37
|
+
qualified.set(`${table}.${name}`, column);
|
|
38
|
+
qualified.set(`${table}.${camel}`, column);
|
|
39
|
+
assignFlat(name, column);
|
|
40
|
+
assignFlat(camel, column);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const key of ambiguous)
|
|
44
|
+
flat.delete(key);
|
|
45
|
+
return { qualified, flat };
|
|
46
|
+
}
|
|
47
|
+
function collectQueryTables(node, out) {
|
|
48
|
+
if (!node || typeof node !== 'object')
|
|
49
|
+
return;
|
|
50
|
+
const op = node;
|
|
51
|
+
if (op.kind === 'TableNode') {
|
|
52
|
+
const name = op.table?.identifier?.name;
|
|
53
|
+
if (typeof name === 'string' && name.length > 0)
|
|
54
|
+
out.add(name);
|
|
55
|
+
}
|
|
56
|
+
for (const value of Object.values(node)) {
|
|
57
|
+
if (Array.isArray(value)) {
|
|
58
|
+
for (const item of value)
|
|
59
|
+
collectQueryTables(item, out);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
collectQueryTables(value, out);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function lookupColumn(manifest, tables, column) {
|
|
67
|
+
let matched;
|
|
68
|
+
for (const table of tables) {
|
|
69
|
+
const found = manifest.qualified.get(`${table}.${column}`);
|
|
70
|
+
if (!found)
|
|
71
|
+
continue;
|
|
72
|
+
if (matched && matched.keyId !== found.keyId) {
|
|
73
|
+
return manifest.flat.get(column);
|
|
74
|
+
}
|
|
75
|
+
matched = found;
|
|
76
|
+
}
|
|
77
|
+
return matched ?? manifest.flat.get(column);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The column names an insert or update writes, paired with the values going
|
|
81
|
+
* into them.
|
|
82
|
+
*
|
|
83
|
+
* Only literal values are collected. A raw expression or a sub-select is
|
|
84
|
+
* something this plugin cannot read and must not guess at, so it is skipped —
|
|
85
|
+
* the guard's job is to catch a plaintext string a call site forgot to encrypt,
|
|
86
|
+
* not to prove every write correct.
|
|
87
|
+
*/
|
|
88
|
+
function collectWrittenValues(node) {
|
|
89
|
+
const written = [];
|
|
90
|
+
const visit = (current) => {
|
|
91
|
+
if (!current || typeof current !== 'object')
|
|
92
|
+
return;
|
|
93
|
+
const op = current;
|
|
94
|
+
if (op.kind === 'ColumnUpdateNode') {
|
|
95
|
+
const name = op.column?.column?.name;
|
|
96
|
+
if (typeof name === 'string' && op.value?.kind === 'ValueNode') {
|
|
97
|
+
written.push([name, op.value.value]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (op.kind === 'InsertQueryNode' && Array.isArray(op.columns)) {
|
|
101
|
+
const names = op.columns.map((c) => c.column?.name);
|
|
102
|
+
const rows = op.values?.values;
|
|
103
|
+
if (Array.isArray(rows)) {
|
|
104
|
+
for (const row of rows) {
|
|
105
|
+
const list = row?.values;
|
|
106
|
+
if (!Array.isArray(list))
|
|
107
|
+
continue;
|
|
108
|
+
list.forEach((value, index) => {
|
|
109
|
+
const name = names[index];
|
|
110
|
+
if (typeof name !== 'string')
|
|
111
|
+
return;
|
|
112
|
+
const unwrapped = value && typeof value === 'object' && 'kind' in value
|
|
113
|
+
? value.kind ===
|
|
114
|
+
'ValueNode'
|
|
115
|
+
? value.value
|
|
116
|
+
: undefined
|
|
117
|
+
: value;
|
|
118
|
+
if (unwrapped !== undefined)
|
|
119
|
+
written.push([name, unwrapped]);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const value of Object.values(current)) {
|
|
125
|
+
if (Array.isArray(value)) {
|
|
126
|
+
for (const item of value)
|
|
127
|
+
visit(item);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
visit(value);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
visit(node);
|
|
135
|
+
return written;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Make a column's at-rest form a property of the column rather than of the
|
|
139
|
+
* developer's memory.
|
|
140
|
+
*
|
|
141
|
+
* Reads are transparent: a `wrapped` column comes back decrypted, so
|
|
142
|
+
* application code never sees an envelope. `hashed` and `plain` columns are
|
|
143
|
+
* untouched, which is what keeps the rest of the schema queryable.
|
|
144
|
+
*
|
|
145
|
+
* Writes are *guarded* rather than transformed, and the reason is Kysely's
|
|
146
|
+
* shape, not a preference: `transformQuery` is synchronous and WebCrypto is
|
|
147
|
+
* not, so a plugin cannot encrypt on the way in. Encrypting at the call site
|
|
148
|
+
* and letting a forgotten call slip through silently is the failure this
|
|
149
|
+
* design exists to prevent, so the guard refuses any plaintext heading for a
|
|
150
|
+
* `wrapped` or `sealed` column. Values are produced with
|
|
151
|
+
* `ClassificationCrypto.encryptColumn`.
|
|
152
|
+
*
|
|
153
|
+
* A `sealed` column is never decrypted on the way out — the application holds
|
|
154
|
+
* only the public half by definition, so returning it as stored is correct
|
|
155
|
+
* rather than a gap.
|
|
156
|
+
*/
|
|
157
|
+
export function createClassificationPlugin(options) {
|
|
158
|
+
const manifest = buildManifest(options.manifest);
|
|
159
|
+
const queryTables = new WeakMap();
|
|
160
|
+
return {
|
|
161
|
+
transformQuery(args) {
|
|
162
|
+
const tables = new Set();
|
|
163
|
+
collectQueryTables(args.node, tables);
|
|
164
|
+
queryTables.set(args.queryId, [...tables]);
|
|
165
|
+
for (const [name, value] of collectWrittenValues(args.node)) {
|
|
166
|
+
if (value === null || value === undefined)
|
|
167
|
+
continue;
|
|
168
|
+
const column = lookupColumn(manifest, [...tables], name);
|
|
169
|
+
if (!column)
|
|
170
|
+
continue;
|
|
171
|
+
if (!isColumnEnvelope(value)) {
|
|
172
|
+
throw new Error(`Refusing to write plaintext into "${name}", which is classified ${column.form}. ` +
|
|
173
|
+
`Produce the value with ClassificationCrypto.encryptColumn() before writing it.`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return args.node;
|
|
177
|
+
},
|
|
178
|
+
async transformResult(args) {
|
|
179
|
+
const tables = queryTables.get(args.queryId) ?? [];
|
|
180
|
+
const rows = [];
|
|
181
|
+
for (const row of args.result.rows) {
|
|
182
|
+
let next;
|
|
183
|
+
for (const [name, value] of Object.entries(row)) {
|
|
184
|
+
if (value === null || value === undefined)
|
|
185
|
+
continue;
|
|
186
|
+
const column = lookupColumn(manifest, tables, name);
|
|
187
|
+
if (!column || column.form !== 'wrapped')
|
|
188
|
+
continue;
|
|
189
|
+
if (!isColumnEnvelope(value))
|
|
190
|
+
continue;
|
|
191
|
+
next ??= { ...row };
|
|
192
|
+
next[name] = await options.crypto.decryptColumn(value);
|
|
193
|
+
}
|
|
194
|
+
rows.push(next ?? row);
|
|
195
|
+
}
|
|
196
|
+
return { ...args.result, rows };
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export { KyselyVirtualUserRunStore } from './kysely-virtual-user-run-store.js';
|
|
|
18
18
|
export { KyselyVirtualUserScheduleStore } from './kysely-virtual-user-schedule-store.js';
|
|
19
19
|
export { SerializePlugin, BaseSerializePlugin, type Serializer, type Deserializer, } from './serialize-plugin.js';
|
|
20
20
|
export { createCoercionPlugin, type ColumnKind, type CoercionMap, type CreateCoercionPluginOptions, } from './coercion-plugin.js';
|
|
21
|
+
export { createClassificationPlugin, type CreateClassificationPluginOptions, } from './classification-plugin.js';
|
|
22
|
+
export { ClassificationCrypto, DEFAULT_KEY_ID, isColumnEnvelope, parseColumnEnvelope, type ClassificationCryptoOptions, type ColumnEnvelope, type KEKResolver, type ResolvedKEK, } from './classification-crypto.js';
|
|
21
23
|
export { agentSchema, auditSchema, channelSchema, credentialSchema, deploymentSchema, scopeSchema, secretSchema, sessionSchema, virtualUserSchema, webhookSchema, workflowSchema, pikkuSchemas, requiredPikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, requirePikkuSchema, resolveRequirements, type PikkuSchema, type RequiredTypes, type UnmetRequirement, type SchemaStatementFactory, } from './schema/index.js';
|
|
22
24
|
export type { KyselyPikkuDB } from './kysely-tables.js';
|
|
23
25
|
export type { WorkflowRunService } from '@pikku/core/workflow';
|
package/dist/src/index.js
CHANGED
|
@@ -18,4 +18,6 @@ export { KyselyVirtualUserRunStore } from './kysely-virtual-user-run-store.js';
|
|
|
18
18
|
export { KyselyVirtualUserScheduleStore } from './kysely-virtual-user-schedule-store.js';
|
|
19
19
|
export { SerializePlugin, BaseSerializePlugin, } from './serialize-plugin.js';
|
|
20
20
|
export { createCoercionPlugin, } from './coercion-plugin.js';
|
|
21
|
+
export { createClassificationPlugin, } from './classification-plugin.js';
|
|
22
|
+
export { ClassificationCrypto, DEFAULT_KEY_ID, isColumnEnvelope, parseColumnEnvelope, } from './classification-crypto.js';
|
|
21
23
|
export { agentSchema, auditSchema, channelSchema, credentialSchema, deploymentSchema, scopeSchema, secretSchema, sessionSchema, virtualUserSchema, webhookSchema, workflowSchema, pikkuSchemas, requiredPikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, requirePikkuSchema, resolveRequirements, } from './schema/index.js';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Kysely } from 'kysely';
|
|
2
|
+
import type { LockRecord, LockVault } from '@pikku/core/classification';
|
|
3
|
+
import type { KyselyPikkuDB } from './kysely-tables.js';
|
|
4
|
+
/**
|
|
5
|
+
* Lock records kept in the application's own database.
|
|
6
|
+
*
|
|
7
|
+
* This is what makes a sealed column survive a restart. `createMemoryLockVault`
|
|
8
|
+
* is fine for a test, but its salts die with the process and a sealed row whose
|
|
9
|
+
* salt is gone is not recoverable by anyone — so a real deployment stores them
|
|
10
|
+
* next to the rows they protect, in the same backup and the same restore.
|
|
11
|
+
*
|
|
12
|
+
* The table holds no plaintext key material, only salts and verifiers, which is
|
|
13
|
+
* why it is readable while the store is locked.
|
|
14
|
+
*/
|
|
15
|
+
export declare class KyselyLockVault implements LockVault {
|
|
16
|
+
private readonly db;
|
|
17
|
+
constructor(db: Kysely<KyselyPikkuDB>);
|
|
18
|
+
read(): Promise<LockRecord[]>;
|
|
19
|
+
/**
|
|
20
|
+
* Replace the whole record set.
|
|
21
|
+
*
|
|
22
|
+
* Whole-set rather than per-row because the callers that write are
|
|
23
|
+
* initialization and rotation, and both produce a complete keyring: applying
|
|
24
|
+
* one as a partial update would leave records from the previous key behind,
|
|
25
|
+
* and a stale salt is indistinguishable from a current one.
|
|
26
|
+
*/
|
|
27
|
+
write(records: LockRecord[]): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Asked through introspection rather than by matching the driver's error
|
|
30
|
+
* text, so the answer does not depend on which engine phrased the failure or
|
|
31
|
+
* on the language it phrased it in.
|
|
32
|
+
*/
|
|
33
|
+
private tableExists;
|
|
34
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { unsafeAsWrapped } from '@pikku/core/classification';
|
|
2
|
+
/**
|
|
3
|
+
* Lock records kept in the application's own database.
|
|
4
|
+
*
|
|
5
|
+
* This is what makes a sealed column survive a restart. `createMemoryLockVault`
|
|
6
|
+
* is fine for a test, but its salts die with the process and a sealed row whose
|
|
7
|
+
* salt is gone is not recoverable by anyone — so a real deployment stores them
|
|
8
|
+
* next to the rows they protect, in the same backup and the same restore.
|
|
9
|
+
*
|
|
10
|
+
* The table holds no plaintext key material, only salts and verifiers, which is
|
|
11
|
+
* why it is readable while the store is locked.
|
|
12
|
+
*/
|
|
13
|
+
export class KyselyLockVault {
|
|
14
|
+
db;
|
|
15
|
+
constructor(db) {
|
|
16
|
+
this.db = db;
|
|
17
|
+
}
|
|
18
|
+
async read() {
|
|
19
|
+
try {
|
|
20
|
+
const rows = await this.db
|
|
21
|
+
.selectFrom('dataLocks')
|
|
22
|
+
.select(['keyId', 'keyVersion', 'salt', 'verifier'])
|
|
23
|
+
.execute();
|
|
24
|
+
return rows.map((row) => ({
|
|
25
|
+
keyId: row.keyId,
|
|
26
|
+
// SQLite hands back whatever it stored and postgres can widen an
|
|
27
|
+
// integer to a string over the wire, while `keyVersion` is compared
|
|
28
|
+
// and stamped as a number.
|
|
29
|
+
keyVersion: Number(row.keyVersion),
|
|
30
|
+
salt: row.salt,
|
|
31
|
+
verifier: unsafeAsWrapped(row.verifier),
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
catch (cause) {
|
|
35
|
+
if (await this.tableExists()) {
|
|
36
|
+
throw cause;
|
|
37
|
+
}
|
|
38
|
+
// No table yet is a legitimate first run — the migration has not been
|
|
39
|
+
// applied, nobody has chosen a passphrase, and the server should come up
|
|
40
|
+
// and offer to initialize rather than refuse to start.
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Replace the whole record set.
|
|
46
|
+
*
|
|
47
|
+
* Whole-set rather than per-row because the callers that write are
|
|
48
|
+
* initialization and rotation, and both produce a complete keyring: applying
|
|
49
|
+
* one as a partial update would leave records from the previous key behind,
|
|
50
|
+
* and a stale salt is indistinguishable from a current one.
|
|
51
|
+
*/
|
|
52
|
+
async write(records) {
|
|
53
|
+
await this.db.transaction().execute(async (trx) => {
|
|
54
|
+
await trx.deleteFrom('dataLocks').execute();
|
|
55
|
+
if (records.length) {
|
|
56
|
+
await trx
|
|
57
|
+
.insertInto('dataLocks')
|
|
58
|
+
.values(records.map((record) => ({
|
|
59
|
+
keyId: record.keyId,
|
|
60
|
+
keyVersion: record.keyVersion,
|
|
61
|
+
salt: record.salt,
|
|
62
|
+
verifier: record.verifier,
|
|
63
|
+
})))
|
|
64
|
+
.execute();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Asked through introspection rather than by matching the driver's error
|
|
70
|
+
* text, so the answer does not depend on which engine phrased the failure or
|
|
71
|
+
* on the language it phrased it in.
|
|
72
|
+
*/
|
|
73
|
+
async tableExists() {
|
|
74
|
+
const tables = await this.db.introspection.getTables();
|
|
75
|
+
return tables.some((table) => table.name === 'data_locks');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -90,7 +90,7 @@ export class KyselySecretService {
|
|
|
90
90
|
.where('key', '=', key)
|
|
91
91
|
.executeTakeFirst();
|
|
92
92
|
if (!row)
|
|
93
|
-
throw new Error(
|
|
93
|
+
throw new Error(`Requested secret not found: ${key}`);
|
|
94
94
|
const kek = await this.getKEK(row.keyVersion);
|
|
95
95
|
const result = await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek);
|
|
96
96
|
await this.logAudit(key, 'read');
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PikkuSchema } from './pikku-schema.types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Where the passphrase's salts and verifiers live.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately the one table in a classified deployment that holds nothing
|
|
6
|
+
* encrypted. It has to be readable before anything has been unlocked — a store
|
|
7
|
+
* that needed its own key to find out how to open itself could never be opened
|
|
8
|
+
* — and that costs nothing, because a salt and a verifier reveal no key
|
|
9
|
+
* material.
|
|
10
|
+
*
|
|
11
|
+
* Losing this table is losing the data: every sealed row is opened by a KEK
|
|
12
|
+
* derived from the salt kept here, so a deployment that ships without the
|
|
13
|
+
* migration silently turns its own rows into noise the first time it restarts.
|
|
14
|
+
*/
|
|
15
|
+
export declare const dataLockSchema: PikkuSchema;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
/**
|
|
3
|
+
* Where the passphrase's salts and verifiers live.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately the one table in a classified deployment that holds nothing
|
|
6
|
+
* encrypted. It has to be readable before anything has been unlocked — a store
|
|
7
|
+
* that needed its own key to find out how to open itself could never be opened
|
|
8
|
+
* — and that costs nothing, because a salt and a verifier reveal no key
|
|
9
|
+
* material.
|
|
10
|
+
*
|
|
11
|
+
* Losing this table is losing the data: every sealed row is opened by a KEK
|
|
12
|
+
* derived from the salt kept here, so a deployment that ships without the
|
|
13
|
+
* migration silently turns its own rows into noise the first time it restarts.
|
|
14
|
+
*/
|
|
15
|
+
export const dataLockSchema = {
|
|
16
|
+
name: 'data-lock',
|
|
17
|
+
ownedBy: ['dataLock'],
|
|
18
|
+
statements: [
|
|
19
|
+
(db) => db.schema
|
|
20
|
+
.createTable('dataLocks')
|
|
21
|
+
.addColumn('keyId', 'varchar(255)', (col) => col.primaryKey())
|
|
22
|
+
.addColumn('keyVersion', 'integer', (col) => col.notNull())
|
|
23
|
+
.addColumn('salt', 'varchar(64)', (col) => col.notNull())
|
|
24
|
+
.addColumn('verifier', 'text', (col) => col.notNull())
|
|
25
|
+
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
|
|
26
|
+
],
|
|
27
|
+
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"root":["../src/coercion-plugin.ts","../src/create-audited-kysely.ts","../src/index.ts","../src/kysely-agent-run-scores.ts","../src/kysely-agent-run-service.ts","../src/kysely-agent-run-state-service.ts","../src/kysely-agent-storage-service.ts","../src/kysely-audit-service.ts","../src/kysely-channel-store.ts","../src/kysely-credential-service.ts","../src/kysely-deployment-service.ts","../src/kysely-eventhub-store.ts","../src/kysely-json.ts","../src/kysely-scope-service.ts","../src/kysely-secret-service.ts","../src/kysely-session-store.ts","../src/kysely-tables.ts","../src/kysely-virtual-user-run-store.ts","../src/kysely-virtual-user-schedule-store.ts","../src/kysely-webhook-service.ts","../src/kysely-workflow-mirror.ts","../src/kysely-workflow-run-service.ts","../src/kysely-workflow-service.ts","../src/serialize-plugin.ts","../src/schema/agent.schema.ts","../src/schema/audit.schema.ts","../src/schema/channel.schema.ts","../src/schema/credential.schema.ts","../src/schema/deployment.schema.ts","../src/schema/index.ts","../src/schema/pikku-schema.types.ts","../src/schema/scope.schema.ts","../src/schema/secret.schema.ts","../src/schema/session.schema.ts","../src/schema/virtual-user-schedule.schema.ts","../src/schema/virtual-user.schema.ts","../src/schema/webhook.schema.ts","../src/schema/workflow.schema.ts","../bin/pikku-kysely-pure.ts"],"version":"6.0.3"}
|
|
1
|
+
{"root":["../src/classification-crypto.ts","../src/classification-plugin.ts","../src/coercion-plugin.ts","../src/create-audited-kysely.ts","../src/index.ts","../src/kysely-agent-run-scores.ts","../src/kysely-agent-run-service.ts","../src/kysely-agent-run-state-service.ts","../src/kysely-agent-storage-service.ts","../src/kysely-audit-service.ts","../src/kysely-channel-store.ts","../src/kysely-credential-service.ts","../src/kysely-deployment-service.ts","../src/kysely-eventhub-store.ts","../src/kysely-json.ts","../src/kysely-scope-service.ts","../src/kysely-secret-service.ts","../src/kysely-session-store.ts","../src/kysely-tables.ts","../src/kysely-virtual-user-run-store.ts","../src/kysely-virtual-user-schedule-store.ts","../src/kysely-webhook-service.ts","../src/kysely-workflow-mirror.ts","../src/kysely-workflow-run-service.ts","../src/kysely-workflow-service.ts","../src/serialize-plugin.ts","../src/schema/agent.schema.ts","../src/schema/audit.schema.ts","../src/schema/channel.schema.ts","../src/schema/credential.schema.ts","../src/schema/deployment.schema.ts","../src/schema/index.ts","../src/schema/pikku-schema.types.ts","../src/schema/scope.schema.ts","../src/schema/secret.schema.ts","../src/schema/session.schema.ts","../src/schema/virtual-user-schedule.schema.ts","../src/schema/virtual-user.schema.ts","../src/schema/webhook.schema.ts","../src/schema/workflow.schema.ts","../bin/pikku-kysely-pure.ts"],"version":"6.0.3"}
|