@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.
@@ -0,0 +1,226 @@
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
+ })
@@ -0,0 +1,265 @@
1
+ import { describe, test, before } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import {
4
+ CamelCasePlugin,
5
+ DummyDriver,
6
+ Kysely,
7
+ PostgresAdapter,
8
+ PostgresIntrospector,
9
+ PostgresQueryCompiler,
10
+ type QueryResult,
11
+ type UnknownRow,
12
+ } from 'kysely'
13
+ import type { ClassificationManifest } from '@pikku/core/classification'
14
+ import { deriveKEK, generateKEKSalt } from '@pikku/core/crypto-utils'
15
+ import {
16
+ ClassificationCrypto,
17
+ type KEKResolver,
18
+ } from './classification-crypto.js'
19
+ import { createClassificationPlugin } from './classification-plugin.js'
20
+
21
+ interface TestDB {
22
+ users: {
23
+ id: string
24
+ email: string
25
+ ssn: string
26
+ tokenHash: string
27
+ recovery: string
28
+ }
29
+ }
30
+
31
+ const manifest: ClassificationManifest = {
32
+ version: 1,
33
+ tables: {
34
+ users: {
35
+ id: { classification: 'public', anonymize_strategy: null },
36
+ email: { classification: 'pii', anonymize_strategy: 'fake:email' },
37
+ ssn: {
38
+ classification: 'secret',
39
+ anonymize_strategy: null,
40
+ form: 'wrapped',
41
+ },
42
+ token_hash: {
43
+ classification: 'secret',
44
+ anonymize_strategy: null,
45
+ form: 'hashed',
46
+ },
47
+ recovery: {
48
+ classification: 'secret',
49
+ anonymize_strategy: null,
50
+ form: 'wrapped',
51
+ keyId: 'recovery-codes',
52
+ },
53
+ },
54
+ },
55
+ }
56
+
57
+ let crypto: ClassificationCrypto
58
+
59
+ /** A key source with nothing to give, the way an unconfigured one behaves. */
60
+ class NoKeyError extends Error {}
61
+ const noKey: KEKResolver = async () => {
62
+ throw new NoKeyError('no key for this column')
63
+ }
64
+
65
+ before(async () => {
66
+ const keks = new Map<string, Promise<CryptoKey>>()
67
+ const resolveKEK: KEKResolver = async (keyId) => {
68
+ let kek = keks.get(keyId)
69
+ if (!kek) {
70
+ kek = deriveKEK(
71
+ 'a-passphrase-long-enough-to-be-real-key-material',
72
+ generateKEKSalt()
73
+ )
74
+ keks.set(keyId, kek)
75
+ }
76
+ return { kek: await kek, keyVersion: 1 }
77
+ }
78
+ crypto = new ClassificationCrypto({ resolveKEK })
79
+ })
80
+
81
+ const plugin = () => createClassificationPlugin({ manifest, crypto })
82
+
83
+ const selectFrom = (tables: string[]) => ({
84
+ kind: 'SelectQueryNode',
85
+ from: {
86
+ kind: 'FromNode',
87
+ froms: tables.map((table) => ({
88
+ kind: 'TableNode',
89
+ table: {
90
+ kind: 'SchemableIdentifierNode',
91
+ identifier: { kind: 'IdentifierNode', name: table },
92
+ },
93
+ })),
94
+ },
95
+ })
96
+
97
+ const readRows = async (
98
+ rows: UnknownRow[],
99
+ tables: string[] = ['users']
100
+ ): Promise<UnknownRow[]> => {
101
+ const p = plugin()
102
+ const queryId = {} as { queryId: string }
103
+ p.transformQuery({ queryId, node: selectFrom(tables) } as any)
104
+ const out = await p.transformResult({
105
+ queryId,
106
+ result: { rows } as QueryResult<UnknownRow>,
107
+ } as any)
108
+ return out.rows as UnknownRow[]
109
+ }
110
+
111
+ const writeDb = () =>
112
+ new Kysely<TestDB>({
113
+ dialect: {
114
+ createAdapter: () => new PostgresAdapter(),
115
+ createDriver: () => new DummyDriver(),
116
+ createIntrospector: (db) => new PostgresIntrospector(db),
117
+ createQueryCompiler: () => new PostgresQueryCompiler(),
118
+ },
119
+ plugins: [new CamelCasePlugin(), plugin()],
120
+ })
121
+
122
+ describe('reading classified columns', () => {
123
+ test('a wrapped column decrypts transparently', async () => {
124
+ const ssn = await crypto.encryptColumn('default', '123-45-6789')
125
+ const [row] = await readRows([{ id: 'u1', ssn }])
126
+ assert.equal(row!.ssn, '123-45-6789')
127
+ })
128
+
129
+ test('a column with its own keyId decrypts under that key', async () => {
130
+ const recovery = await crypto.encryptColumn('recovery-codes', 'abc-def')
131
+ const [row] = await readRows([{ id: 'u1', recovery }])
132
+ assert.equal(row!.recovery, 'abc-def')
133
+ })
134
+
135
+ test('plain and pii columns are left alone', async () => {
136
+ const [row] = await readRows([{ id: 'u1', email: 'a@b.c' }])
137
+ assert.equal(row!.email, 'a@b.c')
138
+ assert.equal(row!.id, 'u1')
139
+ })
140
+
141
+ test('a hashed column is never decrypted — the hash is the lookup key', async () => {
142
+ const tokenHash = 'sha256:deadbeef'
143
+ const [row] = await readRows([{ id: 'u1', tokenHash }])
144
+ assert.equal(row!.tokenHash, tokenHash)
145
+ })
146
+
147
+ test('a null wrapped column stays null', async () => {
148
+ const [row] = await readRows([{ id: 'u1', ssn: null }])
149
+ assert.equal(row!.ssn, null)
150
+ })
151
+
152
+ test('columns of a table the query never touched are left alone', async () => {
153
+ const [row] = await readRows([{ ssn: 'not-an-envelope' }], ['audit'])
154
+ assert.equal(row!.ssn, 'not-an-envelope')
155
+ })
156
+
157
+ test('plain and hashed columns still read without any key at all', async () => {
158
+ const p = createClassificationPlugin({
159
+ manifest,
160
+ crypto: new ClassificationCrypto({ resolveKEK: noKey }),
161
+ })
162
+ const queryId = {} as { queryId: string }
163
+ p.transformQuery({ queryId, node: selectFrom(['users']) } as any)
164
+
165
+ // A sign-in that checks a token hash has to work on a deployment whose key
166
+ // source is unavailable, so a query touching no wrapped column must never
167
+ // reach the resolver at all.
168
+ const out = await p.transformResult({
169
+ queryId,
170
+ result: {
171
+ rows: [{ id: 'u1', email: 'a@b.c', tokenHash: 'sha256:deadbeef' }],
172
+ } as QueryResult<UnknownRow>,
173
+ } as any)
174
+
175
+ assert.deepEqual(out.rows[0], {
176
+ id: 'u1',
177
+ email: 'a@b.c',
178
+ tokenHash: 'sha256:deadbeef',
179
+ })
180
+ })
181
+
182
+ test('a wrapped column read without its key fails loudly', async () => {
183
+ const stored = await crypto.encryptColumn('default', 'ssn')
184
+
185
+ const p = createClassificationPlugin({
186
+ manifest,
187
+ crypto: new ClassificationCrypto({ resolveKEK: noKey }),
188
+ })
189
+ const queryId = {} as { queryId: string }
190
+ p.transformQuery({ queryId, node: selectFrom(['users']) } as any)
191
+
192
+ await assert.rejects(
193
+ () =>
194
+ p.transformResult({
195
+ queryId,
196
+ result: {
197
+ rows: [{ id: 'u1', ssn: stored }],
198
+ } as QueryResult<UnknownRow>,
199
+ } as any),
200
+ (error: unknown) => error instanceof NoKeyError,
201
+ 'handing back an unopened envelope as if it were the value would be worse'
202
+ )
203
+ })
204
+
205
+ test('decrypts under CamelCasePlugin, which renames the column first', async () => {
206
+ const value = await crypto.encryptColumn('default', 'x')
207
+ const [snake] = await readRows([{ token_hash: 'h', ssn: value }])
208
+ assert.equal(snake!.ssn, 'x')
209
+ })
210
+ })
211
+
212
+ describe('writing classified columns', () => {
213
+ test('plaintext into a wrapped column is refused', async () => {
214
+ await assert.rejects(
215
+ () =>
216
+ writeDb()
217
+ .insertInto('users')
218
+ .values({ id: 'u1', ssn: '123-45-6789' as never })
219
+ .execute(),
220
+ /ssn/,
221
+ 'a forgotten encryption must not reach the database'
222
+ )
223
+ })
224
+
225
+ test('a real envelope is accepted', async () => {
226
+ const ssn = await crypto.encryptColumn('default', '123-45-6789')
227
+ await writeDb()
228
+ .insertInto('users')
229
+ .values({ id: 'u1', ssn: ssn as never })
230
+ .execute()
231
+ })
232
+
233
+ test('an update with plaintext is refused too', async () => {
234
+ await assert.rejects(
235
+ () =>
236
+ writeDb()
237
+ .updateTable('users')
238
+ .set({ ssn: 'oops' as never })
239
+ .where('id', '=', 'u1')
240
+ .execute(),
241
+ /ssn/
242
+ )
243
+ })
244
+
245
+ test('unclassified columns are written without complaint', async () => {
246
+ await writeDb()
247
+ .insertInto('users')
248
+ .values({ id: 'u1', email: 'a@b.c' })
249
+ .execute()
250
+ })
251
+
252
+ test('a hashed column takes its hash as-is', async () => {
253
+ await writeDb()
254
+ .insertInto('users')
255
+ .values({ id: 'u1', tokenHash: 'sha256:deadbeef' as never })
256
+ .execute()
257
+ })
258
+
259
+ test('a null wrapped column is allowed', async () => {
260
+ await writeDb()
261
+ .insertInto('users')
262
+ .values({ id: 'u1', ssn: null as never })
263
+ .execute()
264
+ })
265
+ })