@pikku/kysely 0.12.1 → 0.12.3

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,197 @@
1
+ import type { SecretService } from '@pikku/core/services'
2
+ import type { Kysely } from 'kysely'
3
+ import { sql } from 'kysely'
4
+ import type { KyselyPikkuDB } from './kysely-tables.js'
5
+ import {
6
+ envelopeEncrypt,
7
+ envelopeDecrypt,
8
+ envelopeRewrap,
9
+ } from '@pikku/core/crypto-utils'
10
+
11
+ export interface KyselySecretServiceConfig {
12
+ key: string
13
+ keyVersion?: number
14
+ previousKey?: string
15
+ audit?: boolean
16
+ auditReads?: boolean
17
+ }
18
+
19
+ export class KyselySecretService implements SecretService {
20
+ private initialized = false
21
+ private key: string
22
+ private keyVersion: number
23
+ private previousKey?: string
24
+ private audit: boolean
25
+ private auditReads: boolean
26
+
27
+ constructor(
28
+ private db: Kysely<KyselyPikkuDB>,
29
+ config: KyselySecretServiceConfig
30
+ ) {
31
+ this.key = config.key
32
+ this.keyVersion = config.keyVersion ?? 1
33
+ this.previousKey = config.previousKey
34
+ this.audit = config.audit ?? false
35
+ this.auditReads = config.auditReads ?? false
36
+ }
37
+
38
+ public async init(): Promise<void> {
39
+ if (this.initialized) return
40
+
41
+ await this.db.schema
42
+ .createTable('secrets')
43
+ .ifNotExists()
44
+ .addColumn('key', 'varchar(255)', (col) => col.primaryKey())
45
+ .addColumn('ciphertext', 'text', (col) => col.notNull())
46
+ .addColumn('wrapped_dek', 'text', (col) => col.notNull())
47
+ .addColumn('key_version', 'integer', (col) => col.notNull())
48
+ .addColumn('created_at', 'timestamp', (col) =>
49
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
50
+ )
51
+ .addColumn('updated_at', 'timestamp', (col) =>
52
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
53
+ )
54
+ .execute()
55
+
56
+ if (this.audit) {
57
+ await this.db.schema
58
+ .createTable('secrets_audit')
59
+ .ifNotExists()
60
+ .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
61
+ .addColumn('secret_key', 'varchar(255)', (col) => col.notNull())
62
+ .addColumn('action', 'varchar(20)', (col) => col.notNull())
63
+ .addColumn('performed_at', 'timestamp', (col) =>
64
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
65
+ )
66
+ .execute()
67
+ }
68
+
69
+ this.initialized = true
70
+ }
71
+
72
+ private async logAudit(
73
+ secretKey: string,
74
+ action: 'read' | 'write' | 'delete' | 'rotate'
75
+ ): Promise<void> {
76
+ if (!this.audit) return
77
+ if (action === 'read' && !this.auditReads) return
78
+
79
+ await this.db
80
+ .insertInto('secrets_audit')
81
+ .values({
82
+ id: crypto.randomUUID(),
83
+ secret_key: secretKey,
84
+ action,
85
+ performed_at: new Date().toISOString() as any,
86
+ })
87
+ .execute()
88
+ }
89
+
90
+ private getKEK(version: number): string {
91
+ if (version === this.keyVersion) return this.key
92
+ if (this.previousKey) return this.previousKey
93
+ throw new Error(`No KEK available for key_version ${version}`)
94
+ }
95
+
96
+ async getSecret(key: string): Promise<string> {
97
+ const row = await this.db
98
+ .selectFrom('secrets')
99
+ .select(['ciphertext', 'wrapped_dek', 'key_version'])
100
+ .where('key', '=', key)
101
+ .executeTakeFirst()
102
+
103
+ if (!row) throw new Error(`Secret not found: ${key}`)
104
+
105
+ const kek = this.getKEK(row.key_version)
106
+ const result = await envelopeDecrypt<string>(
107
+ kek,
108
+ row.ciphertext,
109
+ row.wrapped_dek
110
+ )
111
+ await this.logAudit(key, 'read')
112
+ return result
113
+ }
114
+
115
+ async getSecretJSON<R = {}>(key: string): Promise<R> {
116
+ const raw = await this.getSecret(key)
117
+ return JSON.parse(raw) as R
118
+ }
119
+
120
+ async hasSecret(key: string): Promise<boolean> {
121
+ const row = await this.db
122
+ .selectFrom('secrets')
123
+ .select('key')
124
+ .where('key', '=', key)
125
+ .executeTakeFirst()
126
+ return !!row
127
+ }
128
+
129
+ async setSecretJSON(key: string, value: unknown): Promise<void> {
130
+ const plaintext = JSON.stringify(value)
131
+ const { ciphertext, wrappedDEK } = await envelopeEncrypt(
132
+ this.key,
133
+ plaintext
134
+ )
135
+ const now = new Date().toISOString()
136
+
137
+ await this.db
138
+ .insertInto('secrets')
139
+ .values({
140
+ key,
141
+ ciphertext,
142
+ wrapped_dek: wrappedDEK,
143
+ key_version: this.keyVersion,
144
+ created_at: now as any,
145
+ updated_at: now as any,
146
+ })
147
+ .onConflict((oc) =>
148
+ oc.column('key').doUpdateSet({
149
+ ciphertext,
150
+ wrapped_dek: wrappedDEK,
151
+ key_version: this.keyVersion,
152
+ updated_at: now as any,
153
+ })
154
+ )
155
+ .execute()
156
+
157
+ await this.logAudit(key, 'write')
158
+ }
159
+
160
+ async deleteSecret(key: string): Promise<void> {
161
+ await this.db.deleteFrom('secrets').where('key', '=', key).execute()
162
+ await this.logAudit(key, 'delete')
163
+ }
164
+
165
+ async rotateKEK(): Promise<number> {
166
+ if (!this.previousKey) {
167
+ throw new Error('No previousKey configured — nothing to rotate from')
168
+ }
169
+
170
+ const rows = await this.db
171
+ .selectFrom('secrets')
172
+ .select(['key', 'wrapped_dek'])
173
+ .where('key_version', '<', this.keyVersion)
174
+ .execute()
175
+
176
+ for (const row of rows) {
177
+ const newWrappedDEK = await envelopeRewrap(
178
+ this.previousKey,
179
+ this.key,
180
+ row.wrapped_dek
181
+ )
182
+ await this.db
183
+ .updateTable('secrets')
184
+ .set({
185
+ wrapped_dek: newWrappedDEK,
186
+ key_version: this.keyVersion,
187
+ updated_at: new Date().toISOString() as any,
188
+ })
189
+ .where('key', '=', row.key)
190
+ .execute()
191
+
192
+ await this.logAudit(row.key, 'rotate')
193
+ }
194
+
195
+ return rows.length
196
+ }
197
+ }