@pikku/kysely 0.13.6 → 0.13.7

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.
@@ -1,3 +1,4 @@
1
+ import { SystemRoleImmutableError, SystemRoleShadowedError, } from '@pikku/core/errors';
1
2
  import { ensurePikkuSchema } from './schema/index.js';
2
3
  import { scopeSchema } from './schema/scope.schema.js';
3
4
  /**
@@ -79,11 +80,69 @@ export class KyselyScopeService {
79
80
  declared: !!row.declared,
80
81
  }));
81
82
  }
83
+ /**
84
+ * Registers the roles declared with `defineSystemRole`.
85
+ *
86
+ * Additive on the same terms as `syncScopes` — a role whose declaration has
87
+ * gone is marked `declared = false` rather than deleted, so a rollback or a
88
+ * rolling deploy cannot strip everyone's grant. Its *scope set* is replaced
89
+ * outright, because that is the declaration's entire content: editing
90
+ * `defineSystemRole` is how you change what a role means, and the deploy is
91
+ * when it takes effect.
92
+ */
93
+ async syncSystemRoles(roles) {
94
+ await this.db.transaction().execute(async (trx) => {
95
+ for (const role of roles) {
96
+ await trx
97
+ .insertInto('pikkuRoles')
98
+ .values({
99
+ name: role.name,
100
+ description: role.description ?? null,
101
+ system: true,
102
+ declared: true,
103
+ })
104
+ .onConflict((oc) => oc.column('name').doUpdateSet((eb) => ({
105
+ description: eb.ref('excluded.description'),
106
+ system: true,
107
+ declared: true,
108
+ })))
109
+ .execute();
110
+ await trx
111
+ .deleteFrom('pikkuRoleScopes')
112
+ .where('role', '=', role.name)
113
+ .execute();
114
+ if (role.scopes.length > 0) {
115
+ await trx
116
+ .insertInto('pikkuRoleScopes')
117
+ .values(role.scopes.map((scope) => ({ role: role.name, scope })))
118
+ .execute();
119
+ }
120
+ }
121
+ // Only system rows are in scope here: an admin's own role is not
122
+ // undeclared, it was never declared, and marking it would take it out of
123
+ // the console for a reason nobody could act on.
124
+ const markStale = trx
125
+ .updateTable('pikkuRoles')
126
+ .set({ declared: false })
127
+ .where('system', '=', true);
128
+ await (roles.length > 0
129
+ ? markStale.where('name', 'not in', roles.map((role) => role.name))
130
+ : markStale).execute();
131
+ });
132
+ }
82
133
  async createRole(role) {
134
+ if (await this.isSystemRole(role.name)) {
135
+ throw new SystemRoleShadowedError(role.name);
136
+ }
83
137
  await this.db.transaction().execute(async (trx) => {
84
138
  await trx
85
139
  .insertInto('pikkuRoles')
86
- .values({ name: role.name, description: role.description ?? null })
140
+ .values({
141
+ name: role.name,
142
+ description: role.description ?? null,
143
+ system: false,
144
+ declared: true,
145
+ })
87
146
  .execute();
88
147
  if (role.scopes.length > 0) {
89
148
  await trx
@@ -94,9 +153,15 @@ export class KyselyScopeService {
94
153
  });
95
154
  }
96
155
  async deleteRole(name) {
156
+ if (await this.isSystemRole(name)) {
157
+ throw new SystemRoleImmutableError(name, 'delete');
158
+ }
97
159
  await this.db.deleteFrom('pikkuRoles').where('name', '=', name).execute();
98
160
  }
99
161
  async setRoleScopes(name, scopes) {
162
+ if (await this.isSystemRole(name)) {
163
+ throw new SystemRoleImmutableError(name, 're-scope');
164
+ }
100
165
  await this.db.transaction().execute(async (trx) => {
101
166
  await trx.deleteFrom('pikkuRoleScopes').where('role', '=', name).execute();
102
167
  if (scopes.length > 0) {
@@ -107,10 +172,25 @@ export class KyselyScopeService {
107
172
  }
108
173
  });
109
174
  }
175
+ /**
176
+ * Whether the store holds this name as a system role.
177
+ *
178
+ * Asked of the store rather than of the generated `SYSTEM_ROLES`, because the
179
+ * store is where a shadow would actually collide — and because a role whose
180
+ * declaration was deleted is still immutable until someone prunes it.
181
+ */
182
+ async isSystemRole(name) {
183
+ const row = await this.db
184
+ .selectFrom('pikkuRoles')
185
+ .select('system')
186
+ .where('name', '=', name)
187
+ .executeTakeFirst();
188
+ return !!row?.system;
189
+ }
110
190
  async listRoles() {
111
191
  const roles = await this.db
112
192
  .selectFrom('pikkuRoles')
113
- .select(['name', 'description'])
193
+ .select(['name', 'description', 'system', 'declared'])
114
194
  .execute();
115
195
  const scopeRows = await this.db
116
196
  .selectFrom('pikkuRoleScopes')
@@ -130,6 +210,8 @@ export class KyselyScopeService {
130
210
  name: role.name,
131
211
  description: role.description ?? undefined,
132
212
  scopes: byRole.get(role.name) ?? [],
213
+ system: !!role.system,
214
+ declared: !!role.declared,
133
215
  }));
134
216
  }
135
217
  async addUserToRole(userId, role, grantedBy) {
@@ -215,4 +297,45 @@ export class KyselyScopeService {
215
297
  .execute();
216
298
  return deleted.map((r) => r.name);
217
299
  }
300
+ /**
301
+ * System roles the last sync found no declaration for, with how many people
302
+ * still hold each. Powers `pikku roles audit`.
303
+ *
304
+ * The count is the whole point: "this role is gone from the code" and "this
305
+ * role is gone from the code and 40 people are standing on it" call for
306
+ * different decisions, and the number is what makes pruning a choice rather
307
+ * than a formality.
308
+ */
309
+ async findStaleSystemRoles() {
310
+ const stale = await this.db
311
+ .selectFrom('pikkuRoles')
312
+ .leftJoin('pikkuUserRole', 'pikkuUserRole.role', 'pikkuRoles.name')
313
+ .select(['pikkuRoles.name as role', 'pikkuUserRole.userId as userId'])
314
+ .where('pikkuRoles.system', '=', true)
315
+ .where('pikkuRoles.declared', '=', false)
316
+ .execute();
317
+ const byRole = new Map();
318
+ for (const row of stale) {
319
+ byRole.set(row.role, (byRole.get(row.role) ?? 0) + (row.userId ? 1 : 0));
320
+ }
321
+ return [...byRole.entries()].map(([role, users]) => ({ role, users }));
322
+ }
323
+ /**
324
+ * Removes undeclared system roles, cascading them out of every user grant
325
+ * that holds them. This revokes access, so it is never run implicitly.
326
+ */
327
+ async pruneSystemRoles() {
328
+ const stale = await this.findStaleSystemRoles();
329
+ if (stale.length === 0) {
330
+ return [];
331
+ }
332
+ const deleted = await this.db
333
+ .deleteFrom('pikkuRoles')
334
+ .where('name', 'in', stale.map((s) => s.role))
335
+ .where('system', '=', true)
336
+ .where('declared', '=', false)
337
+ .returning('name')
338
+ .execute();
339
+ return deleted.map((r) => r.name);
340
+ }
218
341
  }
@@ -11,6 +11,8 @@ export interface KyselySecretServiceConfig {
11
11
  export declare class KyselySecretService implements SecretService {
12
12
  private db;
13
13
  private initialized;
14
+ private kekSalts;
15
+ private keks;
14
16
  private key;
15
17
  private keyVersion;
16
18
  private previousKey?;
@@ -19,6 +21,7 @@ export declare class KyselySecretService implements SecretService {
19
21
  constructor(db: Kysely<KyselyPikkuDB>, config: KyselySecretServiceConfig);
20
22
  init(): Promise<void>;
21
23
  private logAudit;
24
+ private getKEKSalt;
22
25
  private getKEK;
23
26
  getSecret<T = string>(key: string): Promise<T>;
24
27
  hasSecret(key: string): Promise<boolean>;
@@ -1,9 +1,11 @@
1
- import { envelopeEncrypt, envelopeDecrypt, envelopeRewrap, } from '@pikku/core/crypto-utils';
1
+ import { deriveKEK, generateKEKSalt, envelopeEncrypt, envelopeDecrypt, envelopeRewrap, } from '@pikku/core/crypto-utils';
2
2
  import { ensurePikkuSchema } from './schema/index.js';
3
3
  import { secretSchema } from './schema/secret.schema.js';
4
4
  export class KyselySecretService {
5
5
  db;
6
6
  initialized = false;
7
+ kekSalts = new Map();
8
+ keks = new Map();
7
9
  key;
8
10
  keyVersion;
9
11
  previousKey;
@@ -38,12 +40,47 @@ export class KyselySecretService {
38
40
  })
39
41
  .execute();
40
42
  }
41
- getKEK(version) {
42
- if (version === this.keyVersion)
43
- return this.key;
44
- if (this.previousKey)
45
- return this.previousKey;
46
- throw new Error(`No KEK available for key_version ${version}`);
43
+ async getKEKSalt(version) {
44
+ const cached = this.kekSalts.get(version);
45
+ if (cached)
46
+ return cached;
47
+ const existing = await this.db
48
+ .selectFrom('secretKekSalts')
49
+ .select('salt')
50
+ .where('keyVersion', '=', version)
51
+ .executeTakeFirst();
52
+ let salt = existing?.salt;
53
+ if (!salt) {
54
+ await this.db
55
+ .insertInto('secretKekSalts')
56
+ .values({
57
+ keyVersion: version,
58
+ salt: generateKEKSalt(),
59
+ createdAt: new Date().toISOString(),
60
+ })
61
+ .onConflict((oc) => oc.column('keyVersion').doNothing())
62
+ .execute();
63
+ const row = await this.db
64
+ .selectFrom('secretKekSalts')
65
+ .select('salt')
66
+ .where('keyVersion', '=', version)
67
+ .executeTakeFirstOrThrow();
68
+ salt = row.salt;
69
+ }
70
+ this.kekSalts.set(version, salt);
71
+ return salt;
72
+ }
73
+ async getKEK(version) {
74
+ const cached = this.keks.get(version);
75
+ if (cached)
76
+ return cached;
77
+ const passphrase = version === this.keyVersion ? this.key : this.previousKey;
78
+ if (!passphrase) {
79
+ throw new Error(`No KEK available for key_version ${version}`);
80
+ }
81
+ const kek = await deriveKEK(passphrase, await this.getKEKSalt(version));
82
+ this.keks.set(version, kek);
83
+ return kek;
47
84
  }
48
85
  async getSecret(key) {
49
86
  const row = await this.db
@@ -53,7 +90,7 @@ export class KyselySecretService {
53
90
  .executeTakeFirst();
54
91
  if (!row)
55
92
  throw new Error('Requested secret not found');
56
- const kek = this.getKEK(row.keyVersion);
93
+ const kek = await this.getKEK(row.keyVersion);
57
94
  const result = await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek);
58
95
  await this.logAudit(key, 'read');
59
96
  return result;
@@ -67,7 +104,7 @@ export class KyselySecretService {
67
104
  return !!row;
68
105
  }
69
106
  async setSecret(key, value) {
70
- const { ciphertext, wrappedDEK } = await envelopeEncrypt(this.key, value);
107
+ const { ciphertext, wrappedDEK } = await envelopeEncrypt(await this.getKEK(this.keyVersion), value);
71
108
  const now = new Date().toISOString();
72
109
  await this.db
73
110
  .insertInto('secrets')
@@ -101,7 +138,7 @@ export class KyselySecretService {
101
138
  const out = {};
102
139
  for (const row of rows) {
103
140
  try {
104
- const kek = this.getKEK(row.keyVersion);
141
+ const kek = await this.getKEK(row.keyVersion);
105
142
  out[row.key] = await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek);
106
143
  }
107
144
  catch (cause) {
@@ -126,8 +163,10 @@ export class KyselySecretService {
126
163
  .select(['key', 'wrappedDek'])
127
164
  .where('keyVersion', '<', this.keyVersion)
128
165
  .execute();
166
+ const oldKEK = await this.getKEK(this.keyVersion - 1);
167
+ const newKEK = await this.getKEK(this.keyVersion);
129
168
  for (const row of rows) {
130
- const newWrappedDEK = await envelopeRewrap(this.previousKey, this.key, row.wrappedDek);
169
+ const newWrappedDEK = await envelopeRewrap(oldKEK, newKEK, row.wrappedDek);
131
170
  await this.db
132
171
  .updateTable('secrets')
133
172
  .set({
@@ -116,7 +116,12 @@ export interface AIRunTable {
116
116
  agentName: string;
117
117
  threadId: string;
118
118
  resourceId: string;
119
- status: Generated<'running' | 'suspended' | 'completed' | 'failed'>;
119
+ /**
120
+ * `interrupted` is distinct from `failed`: the run was stopped on purpose,
121
+ * usually because the user talked over it. Nothing went wrong, so it should
122
+ * not show up in error reporting.
123
+ */
124
+ status: Generated<'running' | 'suspended' | 'completed' | 'failed' | 'interrupted'>;
120
125
  errorMessage: string | null;
121
126
  suspendReason: 'approval' | 'credential' | 'rpc-missing' | null;
122
127
  missingRpcs: string | null;
@@ -146,6 +151,11 @@ export interface SecretsTable {
146
151
  createdAt: Generated<Date>;
147
152
  updatedAt: Generated<Date>;
148
153
  }
154
+ export interface KekSaltsTable {
155
+ keyVersion: number;
156
+ salt: string;
157
+ createdAt: Generated<Date>;
158
+ }
149
159
  export interface SecretsAuditTable {
150
160
  id: string;
151
161
  secretKey: string;
@@ -191,10 +201,14 @@ export interface PikkuScopesTable {
191
201
  */
192
202
  declared: Generated<boolean>;
193
203
  }
194
- /** A role: an admin-composed bag of scopes. */
204
+ /** A role: a bag of scopes, composed by an admin or declared in code. */
195
205
  export interface PikkuRolesTable {
196
206
  name: string;
197
207
  description: string | null;
208
+ /** Declared with `defineSystemRole`, and therefore not editable here. */
209
+ system: Generated<boolean>;
210
+ /** False for a system role whose declaration has gone: held, but inert. */
211
+ declared: Generated<boolean>;
198
212
  createdAt: Generated<Date>;
199
213
  }
200
214
  export interface PikkuRoleScopesTable {
@@ -254,8 +268,10 @@ export interface KyselyPikkuDB {
254
268
  pikkuDeployments: PikkuDeploymentsTable;
255
269
  pikkuDeploymentFunctions: PikkuDeploymentFunctionsTable;
256
270
  secrets: SecretsTable;
271
+ secretKekSalts: KekSaltsTable;
257
272
  secretsAudit: SecretsAuditTable;
258
273
  credentials: CredentialsTable;
274
+ credentialKekSalts: KekSaltsTable;
259
275
  credentialsAudit: CredentialsAuditTable;
260
276
  pikkuUserSessions: UserSessionsTable;
261
277
  webhookDelivery: WebhookDeliveryTable;
@@ -52,7 +52,6 @@ export declare class KyselyWorkflowService extends PikkuWorkflowService {
52
52
  failedNodeIds: string[];
53
53
  branchKeys: Record<string, string>;
54
54
  }>;
55
- getNodesWithoutSteps(runId: string, nodeIds: string[]): Promise<string[]>;
56
55
  getStepInstances(runId: string): Promise<Array<{
57
56
  stepName: string;
58
57
  status: StepStatus;
@@ -393,18 +393,6 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
393
393
  }
394
394
  return { completedNodeIds, failedNodeIds, branchKeys };
395
395
  }
396
- async getNodesWithoutSteps(runId, nodeIds) {
397
- if (nodeIds.length === 0)
398
- return [];
399
- const result = await this.db
400
- .selectFrom('workflowStep')
401
- .select('stepName')
402
- .where('workflowRunId', '=', runId)
403
- .where('stepName', 'in', nodeIds)
404
- .execute();
405
- const existingStepNames = new Set(result.map((r) => r.stepName));
406
- return nodeIds.filter((id) => !existingStepNames.has(id));
407
- }
408
396
  async getStepInstances(runId) {
409
397
  const rows = await this.db
410
398
  .selectFrom('workflowStep')
@@ -22,6 +22,11 @@ export const credentialSchema = {
22
22
  .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
23
23
  .addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
24
24
  rawStatement(sql `CREATE UNIQUE INDEX credentials_name_user_id_unique ON credentials (name, COALESCE(user_id, ''))`),
25
+ (db) => db.schema
26
+ .createTable('credentialKekSalts')
27
+ .addColumn('keyVersion', 'integer', (col) => col.primaryKey())
28
+ .addColumn('salt', 'varchar(64)', (col) => col.notNull())
29
+ .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
25
30
  (db) => db.schema
26
31
  .createTable('credentialsAudit')
27
32
  .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
@@ -29,6 +29,18 @@ export const scopeSchema = {
29
29
  .createTable('pikkuRoles')
30
30
  .addColumn('name', 'text', (col) => col.primaryKey())
31
31
  .addColumn('description', 'text')
32
+ // Declared in code with `defineSystemRole` rather than composed by an
33
+ // admin. What it buys is the refusals: a system role cannot be renamed,
34
+ // re-scoped or deleted from the console, and a console role cannot be
35
+ // created with its name — two rows answering to one name would make
36
+ // "does Susan hold `buyer`?" depend on which one the store returned.
37
+ .addColumn('system', 'boolean', (col) => col.defaultTo(false).notNull())
38
+ // A system role whose declaration has gone: still held by whoever holds
39
+ // it, no longer offered for new grants, awaiting `pikku roles prune`.
40
+ // The same additive contract as `pikkuScopes.declared`, and for the same
41
+ // reason — a mid-deploy revocation is not something a code edit should
42
+ // be able to cause.
43
+ .addColumn('declared', 'boolean', (col) => col.defaultTo(true).notNull())
32
44
  .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
33
45
  (db) => db.schema
34
46
  .createTable('pikkuRoleScopes')
@@ -19,6 +19,11 @@ export const secretSchema = {
19
19
  .addColumn('keyVersion', 'integer', (col) => col.notNull())
20
20
  .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
21
21
  .addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
22
+ (db) => db.schema
23
+ .createTable('secretKekSalts')
24
+ .addColumn('keyVersion', 'integer', (col) => col.primaryKey())
25
+ .addColumn('salt', 'varchar(64)', (col) => col.notNull())
26
+ .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
22
27
  (db) => db.schema
23
28
  .createTable('secretsAudit')
24
29
  .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/kysely",
3
- "version": "0.13.6",
3
+ "version": "0.13.7",
4
4
  "description": "Kysely-backed Pikku services — workflow state, secrets, sessions, scopes and audit",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -21,7 +21,7 @@
21
21
  "prepublishOnly": "yarn build"
22
22
  },
23
23
  "peerDependencies": {
24
- "@pikku/core": "^0.12.71"
24
+ "@pikku/core": "^0.12.74"
25
25
  },
26
26
  "dependencies": {
27
27
  "kysely": "^0.29.0"
@@ -2,6 +2,8 @@ import type { CredentialService } from '@pikku/core/services'
2
2
  import type { Kysely } from 'kysely'
3
3
  import type { KyselyPikkuDB } from './kysely-tables.js'
4
4
  import {
5
+ deriveKEK,
6
+ generateKEKSalt,
5
7
  envelopeEncrypt,
6
8
  envelopeDecrypt,
7
9
  envelopeRewrap,
@@ -19,6 +21,8 @@ export interface KyselyCredentialServiceConfig {
19
21
 
20
22
  export class KyselyCredentialService implements CredentialService {
21
23
  private initialized = false
24
+ private kekSalts = new Map<number, string>()
25
+ private keks = new Map<number, CryptoKey>()
22
26
  private key: string
23
27
  private keyVersion: number
24
28
  private previousKey?: string
@@ -62,10 +66,52 @@ export class KyselyCredentialService implements CredentialService {
62
66
  .execute()
63
67
  }
64
68
 
65
- private getKEK(version: number): string {
66
- if (version === this.keyVersion) return this.key
67
- if (this.previousKey) return this.previousKey
68
- throw new Error(`No KEK available for key_version ${version}`)
69
+ private async getKEKSalt(version: number): Promise<string> {
70
+ const cached = this.kekSalts.get(version)
71
+ if (cached) return cached
72
+
73
+ const existing = await this.db
74
+ .selectFrom('credentialKekSalts')
75
+ .select('salt')
76
+ .where('keyVersion', '=', version)
77
+ .executeTakeFirst()
78
+
79
+ let salt = existing?.salt
80
+ if (!salt) {
81
+ await this.db
82
+ .insertInto('credentialKekSalts')
83
+ .values({
84
+ keyVersion: version,
85
+ salt: generateKEKSalt(),
86
+ createdAt: new Date().toISOString() as unknown as Date,
87
+ })
88
+ .onConflict((oc) => oc.column('keyVersion').doNothing())
89
+ .execute()
90
+
91
+ const row = await this.db
92
+ .selectFrom('credentialKekSalts')
93
+ .select('salt')
94
+ .where('keyVersion', '=', version)
95
+ .executeTakeFirstOrThrow()
96
+ salt = row.salt
97
+ }
98
+
99
+ this.kekSalts.set(version, salt)
100
+ return salt
101
+ }
102
+
103
+ private async getKEK(version: number): Promise<CryptoKey> {
104
+ const cached = this.keks.get(version)
105
+ if (cached) return cached
106
+
107
+ const passphrase = version === this.keyVersion ? this.key : this.previousKey
108
+ if (!passphrase) {
109
+ throw new Error(`No KEK available for key_version ${version}`)
110
+ }
111
+
112
+ const kek = await deriveKEK(passphrase, await this.getKEKSalt(version))
113
+ this.keks.set(version, kek)
114
+ return kek
69
115
  }
70
116
 
71
117
  private whereUserId(qb: any, userId?: string) {
@@ -84,7 +130,7 @@ export class KyselyCredentialService implements CredentialService {
84
130
  const row = await qb.executeTakeFirst()
85
131
  if (!row) return null
86
132
 
87
- const kek = this.getKEK(row.keyVersion)
133
+ const kek = await this.getKEK(row.keyVersion)
88
134
  const plaintext = await envelopeDecrypt<string>(
89
135
  kek,
90
136
  row.ciphertext,
@@ -102,7 +148,7 @@ export class KyselyCredentialService implements CredentialService {
102
148
  async set(name: string, value: unknown, userId?: string): Promise<void> {
103
149
  const plaintext = JSON.stringify(value)
104
150
  const { ciphertext, wrappedDEK } = await envelopeEncrypt(
105
- this.key,
151
+ await this.getKEK(this.keyVersion),
106
152
  plaintext
107
153
  )
108
154
  const now = new Date().toISOString()
@@ -166,7 +212,7 @@ export class KyselyCredentialService implements CredentialService {
166
212
 
167
213
  const result: Record<string, unknown> = {}
168
214
  for (const row of rows) {
169
- const kek = this.getKEK(row.keyVersion)
215
+ const kek = await this.getKEK(row.keyVersion)
170
216
  const plaintext = await envelopeDecrypt<string>(
171
217
  kek,
172
218
  row.ciphertext,
@@ -216,12 +262,11 @@ export class KyselyCredentialService implements CredentialService {
216
262
  .where('keyVersion', '<', this.keyVersion)
217
263
  .execute()
218
264
 
265
+ const oldKEK = await this.getKEK(this.keyVersion - 1)
266
+ const newKEK = await this.getKEK(this.keyVersion)
267
+
219
268
  for (const row of rows) {
220
- const newWrappedDEK = await envelopeRewrap(
221
- this.previousKey,
222
- this.key,
223
- row.wrappedDek
224
- )
269
+ const newWrappedDEK = await envelopeRewrap(oldKEK, newKEK, row.wrappedDek)
225
270
  let qb = this.db
226
271
  .updateTable('credentials')
227
272
  .set({