@pikku/kysely 0.13.24 → 0.13.28

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.
Files changed (80) hide show
  1. package/CHANGELOG.md +271 -0
  2. package/dist/src/index.d.ts +4 -1
  3. package/dist/src/index.js +3 -1
  4. package/dist/src/kysely-analytics-service.d.ts +30 -0
  5. package/dist/src/kysely-analytics-service.js +80 -0
  6. package/dist/src/kysely-feature-flag-store.d.ts +37 -0
  7. package/dist/src/kysely-feature-flag-store.js +239 -0
  8. package/dist/src/kysely-scope-service.d.ts +1 -0
  9. package/dist/src/kysely-scope-service.js +29 -0
  10. package/dist/src/kysely-tables.d.ts +45 -0
  11. package/dist/src/schema/analytics.schema.d.ts +17 -0
  12. package/dist/src/schema/analytics.schema.js +48 -0
  13. package/dist/src/schema/flag.schema.d.ts +16 -0
  14. package/dist/src/schema/flag.schema.js +62 -0
  15. package/dist/src/schema/index.d.ts +2 -0
  16. package/dist/src/schema/index.js +6 -0
  17. package/package.json +11 -6
  18. package/run-tests.sh +9 -1
  19. package/bin/pikku-kysely-pure.ts +0 -59
  20. package/dist/src/kysely-lock-vault.d.ts +0 -34
  21. package/dist/src/kysely-lock-vault.js +0 -77
  22. package/dist/src/schema/data-lock.schema.d.ts +0 -15
  23. package/dist/src/schema/data-lock.schema.js +0 -27
  24. package/dist/tsconfig.tsbuildinfo +0 -1
  25. package/src/classification-crypto.test.ts +0 -143
  26. package/src/classification-crypto.ts +0 -163
  27. package/src/classification-plugin.pglite.test.ts +0 -226
  28. package/src/classification-plugin.test.ts +0 -265
  29. package/src/classification-plugin.ts +0 -249
  30. package/src/coercion-plugin.test.ts +0 -160
  31. package/src/coercion-plugin.ts +0 -187
  32. package/src/create-audited-kysely.test.ts +0 -560
  33. package/src/create-audited-kysely.ts +0 -254
  34. package/src/index.ts +0 -81
  35. package/src/kysely-agent-run-scores.ts +0 -47
  36. package/src/kysely-agent-run-service.ts +0 -244
  37. package/src/kysely-agent-run-state-service.ts +0 -200
  38. package/src/kysely-agent-storage-service.ts +0 -632
  39. package/src/kysely-audit-service.test.ts +0 -352
  40. package/src/kysely-audit-service.ts +0 -237
  41. package/src/kysely-channel-store.ts +0 -107
  42. package/src/kysely-credential-service.ts +0 -286
  43. package/src/kysely-deployment-service.ts +0 -163
  44. package/src/kysely-eventhub-store.ts +0 -53
  45. package/src/kysely-json.ts +0 -5
  46. package/src/kysely-scope-service.test.ts +0 -418
  47. package/src/kysely-scope-service.ts +0 -425
  48. package/src/kysely-secret-service.ts +0 -242
  49. package/src/kysely-services.test.ts +0 -405
  50. package/src/kysely-session-store.ts +0 -57
  51. package/src/kysely-tables.test.ts +0 -110
  52. package/src/kysely-tables.ts +0 -434
  53. package/src/kysely-virtual-user-run-store.test.ts +0 -369
  54. package/src/kysely-virtual-user-run-store.ts +0 -235
  55. package/src/kysely-virtual-user-schedule-store.test.ts +0 -187
  56. package/src/kysely-virtual-user-schedule-store.ts +0 -187
  57. package/src/kysely-webhook-service.test.ts +0 -153
  58. package/src/kysely-webhook-service.ts +0 -139
  59. package/src/kysely-workflow-mirror.test.ts +0 -259
  60. package/src/kysely-workflow-mirror.ts +0 -374
  61. package/src/kysely-workflow-run-service.ts +0 -305
  62. package/src/kysely-workflow-service.test.ts +0 -544
  63. package/src/kysely-workflow-service.ts +0 -820
  64. package/src/schema/agent.schema.ts +0 -162
  65. package/src/schema/audit.schema.ts +0 -57
  66. package/src/schema/channel.schema.ts +0 -38
  67. package/src/schema/credential.schema.ts +0 -58
  68. package/src/schema/deployment.schema.ts +0 -47
  69. package/src/schema/index.ts +0 -441
  70. package/src/schema/pikku-schema.types.ts +0 -169
  71. package/src/schema/schema.test.ts +0 -591
  72. package/src/schema/scope.schema.ts +0 -110
  73. package/src/schema/secret.schema.ts +0 -49
  74. package/src/schema/session.schema.ts +0 -20
  75. package/src/schema/virtual-user-schedule.schema.ts +0 -48
  76. package/src/schema/virtual-user.schema.ts +0 -106
  77. package/src/schema/webhook.schema.ts +0 -58
  78. package/src/schema/workflow.schema.ts +0 -150
  79. package/src/serialize-plugin.ts +0 -233
  80. package/tsconfig.json +0 -17
@@ -1,59 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { mkdir, readFile, writeFile } from 'fs/promises'
4
- import { dirname } from 'path'
5
- import { fileURLToPath } from 'url'
6
-
7
- const __filename = fileURLToPath(import.meta.url)
8
- const __dirname = dirname(__filename)
9
-
10
- const main = async (output: string, input?: string) => {
11
- let execDirectory = process.cwd()
12
- let inputPath = `${__dirname}/../node_modules/kysely-codegen/dist/db.d.ts`
13
- if (input) {
14
- inputPath = `${execDirectory}/${input}`
15
- }
16
-
17
- let kysely: string
18
- try {
19
- kysely = await readFile(inputPath, 'utf8')
20
- } catch {
21
- console.error(`Could not read file ${input}`)
22
- process.exit(1)
23
- }
24
-
25
- kysely = kysely
26
- .replace('import type { ColumnType } from "kysely";', '')
27
- .replace(
28
- new RegExp('^export\\s+type\\s+Generated<[^>]+>.*?;\\s*$', 'ms'),
29
- ''
30
- )
31
- .replace(/Generated<(.*)>/g, '$1')
32
- .replace(
33
- /export type Numeric =.*;/,
34
- 'export type Numeric = string | number'
35
- )
36
- .replace(/export type Timestamp =.*;/, 'export type Timestamp = Date')
37
- .replace(
38
- /export type ArrayType<T> = ArrayTypeImpl<T> extends \(infer U\)\[\]\s*\?\s*U\[\]\s*:\s*ArrayTypeImpl<T>;\s*export type ArrayTypeImpl<T> = T extends ColumnType<infer S, infer I, infer U>\s*\?\s*ColumnType<S\[\], I\[\], U\[\]>\s*:\s*T\[\];/gs,
39
- ''
40
- )
41
- .replace(/ArrayType<([^>]+)>/g, '$1[]')
42
-
43
- console.log('Writing kysely-pure.gen.ts')
44
-
45
- const outputPath = `${execDirectory}/${output}`
46
- const outputDirectory = dirname(outputPath)
47
- await mkdir(outputDirectory, { recursive: true })
48
- await writeFile(outputPath, kysely, 'utf-8')
49
- console.log(outputPath)
50
- }
51
-
52
- if (process.argv.length !== 4) {
53
- console.error('Expected the following usage:')
54
- console.error('node kysely-pure.js <output> <input>')
55
- process.exit(1)
56
- }
57
-
58
- const [, , output, input] = process.argv
59
- main(output!, input)
@@ -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
- }