@pikku/kysely 0.13.22 → 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 +14 -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/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
|
@@ -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
|
+
})
|