@pikku/kysely 0.13.23 → 0.13.27
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 +248 -0
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.js +3 -1
- package/dist/src/kysely-analytics-service.d.ts +30 -0
- package/dist/src/kysely-analytics-service.js +80 -0
- package/dist/src/kysely-feature-flag-store.d.ts +37 -0
- package/dist/src/kysely-feature-flag-store.js +239 -0
- package/dist/src/kysely-tables.d.ts +45 -0
- package/dist/src/kysely-webhook-service.d.ts +1 -1
- package/dist/src/kysely-webhook-service.js +1 -1
- package/dist/src/schema/analytics.schema.d.ts +17 -0
- package/dist/src/schema/analytics.schema.js +48 -0
- package/dist/src/schema/flag.schema.d.ts +16 -0
- package/dist/src/schema/flag.schema.js +62 -0
- package/dist/src/schema/index.d.ts +2 -0
- package/dist/src/schema/index.js +6 -0
- package/package.json +11 -6
- package/run-tests.sh +9 -1
- package/bin/pikku-kysely-pure.ts +0 -59
- package/dist/src/kysely-lock-vault.d.ts +0 -34
- package/dist/src/kysely-lock-vault.js +0 -77
- package/dist/src/schema/data-lock.schema.d.ts +0 -15
- package/dist/src/schema/data-lock.schema.js +0 -27
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/classification-crypto.test.ts +0 -143
- package/src/classification-crypto.ts +0 -163
- package/src/classification-plugin.pglite.test.ts +0 -226
- package/src/classification-plugin.test.ts +0 -265
- package/src/classification-plugin.ts +0 -249
- package/src/coercion-plugin.test.ts +0 -160
- package/src/coercion-plugin.ts +0 -187
- package/src/create-audited-kysely.test.ts +0 -560
- package/src/create-audited-kysely.ts +0 -254
- package/src/index.ts +0 -81
- package/src/kysely-agent-run-scores.ts +0 -47
- package/src/kysely-agent-run-service.ts +0 -244
- package/src/kysely-agent-run-state-service.ts +0 -200
- package/src/kysely-agent-storage-service.ts +0 -632
- package/src/kysely-audit-service.test.ts +0 -352
- package/src/kysely-audit-service.ts +0 -237
- package/src/kysely-channel-store.ts +0 -107
- package/src/kysely-credential-service.ts +0 -286
- package/src/kysely-deployment-service.ts +0 -163
- package/src/kysely-eventhub-store.ts +0 -53
- package/src/kysely-json.ts +0 -5
- package/src/kysely-scope-service.test.ts +0 -418
- package/src/kysely-scope-service.ts +0 -425
- package/src/kysely-secret-service.ts +0 -242
- package/src/kysely-services.test.ts +0 -405
- package/src/kysely-session-store.ts +0 -57
- package/src/kysely-tables.test.ts +0 -110
- package/src/kysely-tables.ts +0 -434
- package/src/kysely-virtual-user-run-store.test.ts +0 -369
- package/src/kysely-virtual-user-run-store.ts +0 -235
- package/src/kysely-virtual-user-schedule-store.test.ts +0 -187
- package/src/kysely-virtual-user-schedule-store.ts +0 -187
- package/src/kysely-webhook-service.test.ts +0 -124
- package/src/kysely-webhook-service.ts +0 -137
- package/src/kysely-workflow-mirror.test.ts +0 -259
- package/src/kysely-workflow-mirror.ts +0 -374
- package/src/kysely-workflow-run-service.ts +0 -305
- package/src/kysely-workflow-service.test.ts +0 -544
- package/src/kysely-workflow-service.ts +0 -820
- package/src/schema/agent.schema.ts +0 -162
- package/src/schema/audit.schema.ts +0 -57
- package/src/schema/channel.schema.ts +0 -38
- package/src/schema/credential.schema.ts +0 -58
- package/src/schema/deployment.schema.ts +0 -47
- package/src/schema/index.ts +0 -441
- package/src/schema/pikku-schema.types.ts +0 -169
- package/src/schema/schema.test.ts +0 -591
- package/src/schema/scope.schema.ts +0 -110
- package/src/schema/secret.schema.ts +0 -49
- package/src/schema/session.schema.ts +0 -20
- package/src/schema/virtual-user-schedule.schema.ts +0 -48
- package/src/schema/virtual-user.schema.ts +0 -106
- package/src/schema/webhook.schema.ts +0 -58
- package/src/schema/workflow.schema.ts +0 -150
- package/src/serialize-plugin.ts +0 -233
- package/tsconfig.json +0 -17
|
@@ -1,34 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,77 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
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;
|
|
@@ -1,27 +0,0 @@
|
|
|
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 +0,0 @@
|
|
|
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"}
|
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
import { describe, test } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import { deriveKEK, generateKEKSalt } from '@pikku/core/crypto-utils'
|
|
4
|
-
import {
|
|
5
|
-
ClassificationCrypto,
|
|
6
|
-
DEFAULT_KEY_ID,
|
|
7
|
-
isColumnEnvelope,
|
|
8
|
-
parseColumnEnvelope,
|
|
9
|
-
type KEKResolver,
|
|
10
|
-
} from './classification-crypto.js'
|
|
11
|
-
|
|
12
|
-
const passphrase = 'a-passphrase-long-enough-to-be-real-key-material'
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* A resolver that derives a KEK per keyId and remembers it.
|
|
16
|
-
*
|
|
17
|
-
* Caching is the part under test as much as the derivation: `resolveKEK` is
|
|
18
|
-
* called per operation, and a resolver that re-derived every time would pay
|
|
19
|
-
* key stretching on every row.
|
|
20
|
-
*/
|
|
21
|
-
const testResolver = (): KEKResolver => {
|
|
22
|
-
const keks = new Map<string, Promise<CryptoKey>>()
|
|
23
|
-
return async (keyId) => {
|
|
24
|
-
let kek = keks.get(keyId)
|
|
25
|
-
if (!kek) {
|
|
26
|
-
kek = deriveKEK(passphrase, generateKEKSalt())
|
|
27
|
-
keks.set(keyId, kek)
|
|
28
|
-
}
|
|
29
|
-
return { kek: await kek, keyVersion: 1 }
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const staticCrypto = async () =>
|
|
34
|
-
new ClassificationCrypto({ resolveKEK: testResolver() })
|
|
35
|
-
|
|
36
|
-
describe('column envelope', () => {
|
|
37
|
-
test('round-trips a value through the default key', async () => {
|
|
38
|
-
const crypto = await staticCrypto()
|
|
39
|
-
const envelope = await crypto.encryptColumn(DEFAULT_KEY_ID, 'hunter2')
|
|
40
|
-
assert.equal(await crypto.decryptColumn(envelope), 'hunter2')
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
test('the stored envelope names the key that protects it', async () => {
|
|
44
|
-
const crypto = await staticCrypto()
|
|
45
|
-
const envelope = await crypto.encryptColumn('credentials', 'shh')
|
|
46
|
-
const parsed = parseColumnEnvelope(envelope)
|
|
47
|
-
assert.equal(parsed?.keyId, 'credentials')
|
|
48
|
-
assert.equal(parsed?.keyVersion, 1)
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
test('a value is not stored in the clear', async () => {
|
|
52
|
-
const crypto = await staticCrypto()
|
|
53
|
-
const envelope = await crypto.encryptColumn(DEFAULT_KEY_ID, 'hunter2')
|
|
54
|
-
assert.ok(!envelope.includes('hunter2'))
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
test('every value gets its own DEK', async () => {
|
|
58
|
-
const crypto = await staticCrypto()
|
|
59
|
-
const a = parseColumnEnvelope(
|
|
60
|
-
await crypto.encryptColumn(DEFAULT_KEY_ID, 'same')
|
|
61
|
-
)
|
|
62
|
-
const b = parseColumnEnvelope(
|
|
63
|
-
await crypto.encryptColumn(DEFAULT_KEY_ID, 'same')
|
|
64
|
-
)
|
|
65
|
-
assert.notEqual(a?.wrappedDek, b?.wrappedDek)
|
|
66
|
-
assert.notEqual(a?.ciphertext, b?.ciphertext)
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
test('round-trips non-string values', async () => {
|
|
70
|
-
const crypto = await staticCrypto()
|
|
71
|
-
const value = { card: '4242', meta: [1, 2, 3] }
|
|
72
|
-
const envelope = await crypto.encryptColumn(DEFAULT_KEY_ID, value)
|
|
73
|
-
assert.deepEqual(await crypto.decryptColumn(envelope), value)
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
test('plaintext is not mistaken for an envelope', () => {
|
|
77
|
-
assert.equal(isColumnEnvelope('hunter2'), false)
|
|
78
|
-
assert.equal(isColumnEnvelope(''), false)
|
|
79
|
-
assert.equal(parseColumnEnvelope('hunter2'), null)
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
test('a keyId containing the separator survives the round trip', async () => {
|
|
83
|
-
const crypto = await staticCrypto()
|
|
84
|
-
const envelope = await crypto.encryptColumn('tenant.42', 'x')
|
|
85
|
-
assert.equal(parseColumnEnvelope(envelope)?.keyId, 'tenant.42')
|
|
86
|
-
assert.equal(await crypto.decryptColumn(envelope), 'x')
|
|
87
|
-
})
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
describe('KEK scoping', () => {
|
|
91
|
-
test('different keyIds derive different KEKs from one passphrase', async () => {
|
|
92
|
-
const resolve = testResolver()
|
|
93
|
-
const a = await resolve(DEFAULT_KEY_ID)
|
|
94
|
-
const b = await resolve('credentials')
|
|
95
|
-
assert.notEqual(a.kek, b.kek)
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
test('a value written under one keyId does not open under another', async () => {
|
|
99
|
-
const crypto = await staticCrypto()
|
|
100
|
-
const envelope = await crypto.encryptColumn('credentials', 'shh')
|
|
101
|
-
const forged = envelope.replace(
|
|
102
|
-
/^(pikku1\.)[^.]+/,
|
|
103
|
-
`$1${Buffer.from('notes').toString('base64url')}`
|
|
104
|
-
)
|
|
105
|
-
await assert.rejects(() => crypto.decryptColumn(forged))
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
test('the same keyId resolves to the same KEK, so derivation is paid once', async () => {
|
|
109
|
-
const resolve = testResolver()
|
|
110
|
-
assert.equal(
|
|
111
|
-
(await resolve(DEFAULT_KEY_ID)).kek,
|
|
112
|
-
(await resolve(DEFAULT_KEY_ID)).kek
|
|
113
|
-
)
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
test('rewrapping moves a value to a new key without touching the ciphertext', async () => {
|
|
117
|
-
const crypto = await staticCrypto()
|
|
118
|
-
const envelope = await crypto.encryptColumn(DEFAULT_KEY_ID, 'portable')
|
|
119
|
-
const before = parseColumnEnvelope(envelope)
|
|
120
|
-
|
|
121
|
-
const rewrapped = await crypto.rewrapColumn(envelope, 'credentials')
|
|
122
|
-
const after = parseColumnEnvelope(rewrapped)
|
|
123
|
-
|
|
124
|
-
assert.equal(after?.keyId, 'credentials')
|
|
125
|
-
assert.equal(
|
|
126
|
-
after?.ciphertext,
|
|
127
|
-
before?.ciphertext,
|
|
128
|
-
'rewrap must re-wrap the DEK, not re-encrypt the value'
|
|
129
|
-
)
|
|
130
|
-
assert.notEqual(after?.wrappedDek, before?.wrappedDek)
|
|
131
|
-
assert.equal(await crypto.decryptColumn(rewrapped), 'portable')
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
test('a resolver may hand back a KEK it derived any way it likes', async () => {
|
|
135
|
-
const kek = await deriveKEK(passphrase, generateKEKSalt())
|
|
136
|
-
const crypto = new ClassificationCrypto({
|
|
137
|
-
resolveKEK: async () => ({ kek, keyVersion: 7 }),
|
|
138
|
-
})
|
|
139
|
-
const envelope = await crypto.encryptColumn('anything', 'v')
|
|
140
|
-
assert.equal(parseColumnEnvelope(envelope)?.keyVersion, 7)
|
|
141
|
-
assert.equal(await crypto.decryptColumn(envelope), 'v')
|
|
142
|
-
})
|
|
143
|
-
})
|
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
envelopeDecrypt,
|
|
3
|
-
envelopeEncrypt,
|
|
4
|
-
envelopeRewrap,
|
|
5
|
-
} from '@pikku/core/crypto-utils'
|
|
6
|
-
import { DEFAULT_KEY_ID } from '@pikku/core/classification'
|
|
7
|
-
import type { WrappedValue } from '@pikku/core/classification'
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* The key a column is protected by when its classification names none. A
|
|
11
|
-
* deployment that never scopes its keys stores this id in every row and still
|
|
12
|
-
* gets the scoping seam for free.
|
|
13
|
-
*
|
|
14
|
-
* Re-exported from core rather than declared again: the same constant decides
|
|
15
|
-
* which record `initialize` mints and which one a stored envelope names, and
|
|
16
|
-
* two copies of it would only ever be found to disagree by a row that could
|
|
17
|
-
* not be opened.
|
|
18
|
-
*/
|
|
19
|
-
export { DEFAULT_KEY_ID }
|
|
20
|
-
|
|
21
|
-
const ENVELOPE_PREFIX = 'pikku1'
|
|
22
|
-
|
|
23
|
-
/** The parts of a stored envelope, as they came off the column. */
|
|
24
|
-
export interface ColumnEnvelope {
|
|
25
|
-
keyId: string
|
|
26
|
-
keyVersion: number
|
|
27
|
-
wrappedDek: string
|
|
28
|
-
ciphertext: string
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* A KEK for one `keyId`, plus the version it was derived at.
|
|
33
|
-
*
|
|
34
|
-
* The version is the resolver's to report rather than the caller's to ask for:
|
|
35
|
-
* on a write it stamps the row, and on a read the row already carries the
|
|
36
|
-
* version the value was written under.
|
|
37
|
-
*/
|
|
38
|
-
export interface ResolvedKEK {
|
|
39
|
-
kek: CryptoKey
|
|
40
|
-
keyVersion: number
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Maps a `keyId` — and, when reading, the version the row was written under —
|
|
45
|
-
* to the KEK that opens it.
|
|
46
|
-
*
|
|
47
|
-
* This is the seam the whole design turns on. An unlock gate, a per-tenant
|
|
48
|
-
* lookup or a KMS call all replace this one function without reshaping anything
|
|
49
|
-
* that calls it.
|
|
50
|
-
*/
|
|
51
|
-
export type KEKResolver = (
|
|
52
|
-
keyId: string,
|
|
53
|
-
keyVersion?: number
|
|
54
|
-
) => Promise<ResolvedKEK>
|
|
55
|
-
|
|
56
|
-
const encodePart = (value: string): string =>
|
|
57
|
-
Buffer.from(value, 'utf8').toString('base64url')
|
|
58
|
-
|
|
59
|
-
const decodePart = (value: string): string =>
|
|
60
|
-
Buffer.from(value, 'base64url').toString('utf8')
|
|
61
|
-
|
|
62
|
-
const serializeEnvelope = (envelope: ColumnEnvelope): WrappedValue =>
|
|
63
|
-
[
|
|
64
|
-
ENVELOPE_PREFIX,
|
|
65
|
-
encodePart(envelope.keyId),
|
|
66
|
-
String(envelope.keyVersion),
|
|
67
|
-
envelope.wrappedDek,
|
|
68
|
-
envelope.ciphertext,
|
|
69
|
-
].join('.') as WrappedValue
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Read a stored envelope, or null when the value is not one.
|
|
73
|
-
*
|
|
74
|
-
* Every part is base64url except the version, so `.` cannot occur inside a part
|
|
75
|
-
* and a fixed split is unambiguous — a `keyId` may contain dots.
|
|
76
|
-
*/
|
|
77
|
-
export const parseColumnEnvelope = (value: unknown): ColumnEnvelope | null => {
|
|
78
|
-
if (typeof value !== 'string') return null
|
|
79
|
-
const parts = value.split('.')
|
|
80
|
-
if (parts.length !== 5 || parts[0] !== ENVELOPE_PREFIX) return null
|
|
81
|
-
|
|
82
|
-
const [, encodedKeyId, rawVersion, wrappedDek, ciphertext] = parts
|
|
83
|
-
const keyVersion = Number(rawVersion)
|
|
84
|
-
if (!Number.isInteger(keyVersion) || !wrappedDek || !ciphertext) return null
|
|
85
|
-
|
|
86
|
-
try {
|
|
87
|
-
return {
|
|
88
|
-
keyId: decodePart(encodedKeyId!),
|
|
89
|
-
keyVersion,
|
|
90
|
-
wrappedDek,
|
|
91
|
-
ciphertext,
|
|
92
|
-
}
|
|
93
|
-
} catch {
|
|
94
|
-
return null
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export const isColumnEnvelope = (value: unknown): boolean =>
|
|
99
|
-
parseColumnEnvelope(value) !== null
|
|
100
|
-
|
|
101
|
-
export interface ClassificationCryptoOptions {
|
|
102
|
-
resolveKEK: KEKResolver
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Envelope encryption for a single classified column.
|
|
107
|
-
*
|
|
108
|
-
* Unlike `KyselySecretService`, which owns its table and can afford a column
|
|
109
|
-
* per envelope part, a classified column is one column in someone else's table.
|
|
110
|
-
* So the envelope is self-describing: the `keyId` and `keyVersion` travel in
|
|
111
|
-
* the value, which is what lets a row record the key that protects it without
|
|
112
|
-
* a schema change to every table that has a secret in it.
|
|
113
|
-
*/
|
|
114
|
-
export class ClassificationCrypto {
|
|
115
|
-
private readonly resolveKEK: KEKResolver
|
|
116
|
-
|
|
117
|
-
constructor(options: ClassificationCryptoOptions) {
|
|
118
|
-
this.resolveKEK = options.resolveKEK
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
async encryptColumn(keyId: string, value: unknown): Promise<WrappedValue> {
|
|
122
|
-
const { kek, keyVersion } = await this.resolveKEK(keyId)
|
|
123
|
-
const { ciphertext, wrappedDEK } = await envelopeEncrypt(kek, value)
|
|
124
|
-
return serializeEnvelope({
|
|
125
|
-
keyId,
|
|
126
|
-
keyVersion,
|
|
127
|
-
wrappedDek: wrappedDEK,
|
|
128
|
-
ciphertext,
|
|
129
|
-
})
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
async decryptColumn<T = unknown>(value: unknown): Promise<T> {
|
|
133
|
-
const envelope = parseColumnEnvelope(value)
|
|
134
|
-
if (!envelope) {
|
|
135
|
-
throw new Error('Value is not a pikku column envelope')
|
|
136
|
-
}
|
|
137
|
-
const { kek } = await this.resolveKEK(envelope.keyId, envelope.keyVersion)
|
|
138
|
-
return envelopeDecrypt<T>(kek, envelope.ciphertext, envelope.wrappedDek)
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Move a value to another key by re-wrapping its DEK. The ciphertext is
|
|
143
|
-
* untouched, so the cost is a constant per row rather than proportional to
|
|
144
|
-
* the value — the property that makes rescoping and rotation affordable.
|
|
145
|
-
*/
|
|
146
|
-
async rewrapColumn(value: unknown, newKeyId: string): Promise<WrappedValue> {
|
|
147
|
-
const envelope = parseColumnEnvelope(value)
|
|
148
|
-
if (!envelope) {
|
|
149
|
-
throw new Error('Value is not a pikku column envelope')
|
|
150
|
-
}
|
|
151
|
-
const { kek: oldKEK } = await this.resolveKEK(
|
|
152
|
-
envelope.keyId,
|
|
153
|
-
envelope.keyVersion
|
|
154
|
-
)
|
|
155
|
-
const { kek: newKEK, keyVersion } = await this.resolveKEK(newKeyId)
|
|
156
|
-
return serializeEnvelope({
|
|
157
|
-
keyId: newKeyId,
|
|
158
|
-
keyVersion,
|
|
159
|
-
wrappedDek: await envelopeRewrap(oldKEK, newKEK, envelope.wrappedDek),
|
|
160
|
-
ciphertext: envelope.ciphertext,
|
|
161
|
-
})
|
|
162
|
-
}
|
|
163
|
-
}
|
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
import { describe, test, before, after } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import {
|
|
4
|
-
CamelCasePlugin,
|
|
5
|
-
Kysely,
|
|
6
|
-
PostgresAdapter,
|
|
7
|
-
PostgresIntrospector,
|
|
8
|
-
PostgresQueryCompiler,
|
|
9
|
-
sql,
|
|
10
|
-
type CompiledQuery,
|
|
11
|
-
type DatabaseConnection,
|
|
12
|
-
type Driver,
|
|
13
|
-
type QueryResult,
|
|
14
|
-
} from 'kysely'
|
|
15
|
-
import { PGlite } from '@electric-sql/pglite'
|
|
16
|
-
import type { ClassificationManifest } from '@pikku/core/classification'
|
|
17
|
-
import { deriveKEK, generateKEKSalt } from '@pikku/core/crypto-utils'
|
|
18
|
-
import {
|
|
19
|
-
ClassificationCrypto,
|
|
20
|
-
type KEKResolver,
|
|
21
|
-
} from './classification-crypto.js'
|
|
22
|
-
import { createClassificationPlugin } from './classification-plugin.js'
|
|
23
|
-
|
|
24
|
-
interface AccountsDB {
|
|
25
|
-
accounts: {
|
|
26
|
-
id: string
|
|
27
|
-
email: string
|
|
28
|
-
ssn: string
|
|
29
|
-
recovery: string
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const manifest: ClassificationManifest = {
|
|
34
|
-
version: 1,
|
|
35
|
-
tables: {
|
|
36
|
-
accounts: {
|
|
37
|
-
id: { classification: 'public', anonymize_strategy: null },
|
|
38
|
-
email: { classification: 'pii', anonymize_strategy: 'fake:email' },
|
|
39
|
-
ssn: {
|
|
40
|
-
classification: 'secret',
|
|
41
|
-
anonymize_strategy: null,
|
|
42
|
-
form: 'wrapped',
|
|
43
|
-
},
|
|
44
|
-
recovery: {
|
|
45
|
-
classification: 'secret',
|
|
46
|
-
anonymize_strategy: null,
|
|
47
|
-
form: 'wrapped',
|
|
48
|
-
keyId: 'recovery-codes',
|
|
49
|
-
},
|
|
50
|
-
},
|
|
51
|
-
},
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
class PGliteConnection implements DatabaseConnection {
|
|
55
|
-
constructor(private readonly db: PGlite) {}
|
|
56
|
-
|
|
57
|
-
async executeQuery<R>(query: CompiledQuery): Promise<QueryResult<R>> {
|
|
58
|
-
const result = await this.db.query<R>(query.sql, [...query.parameters])
|
|
59
|
-
return {
|
|
60
|
-
rows: result.rows,
|
|
61
|
-
numAffectedRows:
|
|
62
|
-
result.affectedRows !== undefined
|
|
63
|
-
? BigInt(result.affectedRows)
|
|
64
|
-
: undefined,
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async *streamQuery<R>(
|
|
69
|
-
query: CompiledQuery
|
|
70
|
-
): AsyncIterableIterator<QueryResult<R>> {
|
|
71
|
-
yield await this.executeQuery<R>(query)
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
class PGliteDriver implements Driver {
|
|
76
|
-
private readonly connection: PGliteConnection
|
|
77
|
-
constructor(private readonly db: PGlite) {
|
|
78
|
-
this.connection = new PGliteConnection(db)
|
|
79
|
-
}
|
|
80
|
-
async init(): Promise<void> {}
|
|
81
|
-
async acquireConnection(): Promise<DatabaseConnection> {
|
|
82
|
-
return this.connection
|
|
83
|
-
}
|
|
84
|
-
async beginTransaction(): Promise<void> {
|
|
85
|
-
await this.db.exec('BEGIN')
|
|
86
|
-
}
|
|
87
|
-
async commitTransaction(): Promise<void> {
|
|
88
|
-
await this.db.exec('COMMIT')
|
|
89
|
-
}
|
|
90
|
-
async rollbackTransaction(): Promise<void> {
|
|
91
|
-
await this.db.exec('ROLLBACK')
|
|
92
|
-
}
|
|
93
|
-
async releaseConnection(): Promise<void> {}
|
|
94
|
-
async destroy(): Promise<void> {}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const PASSPHRASE = 'a-passphrase-long-enough-to-be-real-key-material'
|
|
98
|
-
|
|
99
|
-
let pglite: PGlite
|
|
100
|
-
let db: Kysely<AccountsDB>
|
|
101
|
-
let crypto: ClassificationCrypto
|
|
102
|
-
|
|
103
|
-
before(async () => {
|
|
104
|
-
const keks = new Map<string, Promise<CryptoKey>>()
|
|
105
|
-
const resolveKEK: KEKResolver = async (keyId) => {
|
|
106
|
-
let kek = keks.get(keyId)
|
|
107
|
-
if (!kek) {
|
|
108
|
-
kek = deriveKEK(PASSPHRASE, generateKEKSalt())
|
|
109
|
-
keks.set(keyId, kek)
|
|
110
|
-
}
|
|
111
|
-
return { kek: await kek, keyVersion: 1 }
|
|
112
|
-
}
|
|
113
|
-
crypto = new ClassificationCrypto({ resolveKEK })
|
|
114
|
-
|
|
115
|
-
pglite = new PGlite()
|
|
116
|
-
db = new Kysely<AccountsDB>({
|
|
117
|
-
dialect: {
|
|
118
|
-
createAdapter: () => new PostgresAdapter(),
|
|
119
|
-
createDriver: () => new PGliteDriver(pglite),
|
|
120
|
-
createIntrospector: (i) => new PostgresIntrospector(i),
|
|
121
|
-
createQueryCompiler: () => new PostgresQueryCompiler(),
|
|
122
|
-
},
|
|
123
|
-
plugins: [
|
|
124
|
-
new CamelCasePlugin(),
|
|
125
|
-
createClassificationPlugin({
|
|
126
|
-
manifest,
|
|
127
|
-
crypto,
|
|
128
|
-
}),
|
|
129
|
-
],
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
await pglite.exec(`
|
|
133
|
-
CREATE TABLE accounts (
|
|
134
|
-
id TEXT PRIMARY KEY,
|
|
135
|
-
email TEXT NOT NULL,
|
|
136
|
-
ssn TEXT,
|
|
137
|
-
recovery TEXT
|
|
138
|
-
)
|
|
139
|
-
`)
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
after(async () => {
|
|
143
|
-
await pglite.close()
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
describe('against a real postgres', () => {
|
|
147
|
-
test('a wrapped column round-trips through the database', async () => {
|
|
148
|
-
await db
|
|
149
|
-
.insertInto('accounts')
|
|
150
|
-
.values({
|
|
151
|
-
id: 'a1',
|
|
152
|
-
email: 'a@b.c',
|
|
153
|
-
ssn: (await crypto.encryptColumn('default', '123-45-6789')) as never,
|
|
154
|
-
recovery: (await crypto.encryptColumn(
|
|
155
|
-
'recovery-codes',
|
|
156
|
-
'zzz-999'
|
|
157
|
-
)) as never,
|
|
158
|
-
})
|
|
159
|
-
.execute()
|
|
160
|
-
|
|
161
|
-
const row = await db
|
|
162
|
-
.selectFrom('accounts')
|
|
163
|
-
.selectAll()
|
|
164
|
-
.where('id', '=', 'a1')
|
|
165
|
-
.executeTakeFirstOrThrow()
|
|
166
|
-
|
|
167
|
-
assert.equal(row.ssn, '123-45-6789')
|
|
168
|
-
assert.equal(row.recovery, 'zzz-999')
|
|
169
|
-
assert.equal(row.email, 'a@b.c')
|
|
170
|
-
})
|
|
171
|
-
|
|
172
|
-
test('what is actually on disk is ciphertext', async () => {
|
|
173
|
-
const raw = await pglite.query<{ ssn: string }>(
|
|
174
|
-
`SELECT ssn FROM accounts WHERE id = 'a1'`
|
|
175
|
-
)
|
|
176
|
-
const stored = raw.rows[0]!.ssn
|
|
177
|
-
assert.ok(stored.startsWith('pikku1.'))
|
|
178
|
-
assert.ok(!stored.includes('123-45-6789'))
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
test('a pii column is still queryable, which is the point', async () => {
|
|
182
|
-
const row = await db
|
|
183
|
-
.selectFrom('accounts')
|
|
184
|
-
.select('id')
|
|
185
|
-
.where('email', '=', 'a@b.c')
|
|
186
|
-
.executeTakeFirst()
|
|
187
|
-
assert.equal(row?.id, 'a1')
|
|
188
|
-
})
|
|
189
|
-
|
|
190
|
-
test('a forgotten encryption never reaches the database', async () => {
|
|
191
|
-
await assert.rejects(
|
|
192
|
-
() =>
|
|
193
|
-
db
|
|
194
|
-
.insertInto('accounts')
|
|
195
|
-
.values({ id: 'a2', email: 'x@y.z', ssn: 'raw-ssn' as never })
|
|
196
|
-
.execute(),
|
|
197
|
-
/ssn/
|
|
198
|
-
)
|
|
199
|
-
const found = await db
|
|
200
|
-
.selectFrom('accounts')
|
|
201
|
-
.select('id')
|
|
202
|
-
.where('id', '=', 'a2')
|
|
203
|
-
.executeTakeFirst()
|
|
204
|
-
assert.equal(found, undefined)
|
|
205
|
-
})
|
|
206
|
-
|
|
207
|
-
test('a rewrapped value still reads back through the plugin', async () => {
|
|
208
|
-
const raw = await pglite.query<{ ssn: string }>(
|
|
209
|
-
`SELECT ssn FROM accounts WHERE id = 'a1'`
|
|
210
|
-
)
|
|
211
|
-
const rewrapped = await crypto.rewrapColumn(
|
|
212
|
-
raw.rows[0]!.ssn,
|
|
213
|
-
'recovery-codes'
|
|
214
|
-
)
|
|
215
|
-
await sql`UPDATE accounts SET ssn = ${rewrapped} WHERE id = 'a1'`.execute(
|
|
216
|
-
db
|
|
217
|
-
)
|
|
218
|
-
|
|
219
|
-
const row = await db
|
|
220
|
-
.selectFrom('accounts')
|
|
221
|
-
.select('ssn')
|
|
222
|
-
.where('id', '=', 'a1')
|
|
223
|
-
.executeTakeFirstOrThrow()
|
|
224
|
-
assert.equal(row.ssn, '123-45-6789')
|
|
225
|
-
})
|
|
226
|
-
})
|